From 8b196e203f04edac2aa2c7a29987df3ca8d6bb6e Mon Sep 17 00:00:00 2001 From: rjsgud49 Date: Wed, 21 Oct 2026 01:48:16 +0000 Subject: [PATCH] =?UTF-8?q?feat(mobile):=20=EA=B3=B5=EC=8B=9D=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=EC=97=90=20=EC=B0=B8=EA=B0=80=20=EB=8C=80=ED=9A=8C?= =?UTF-8?q?=EC=99=80=20=EC=98=A4=EB=8A=98=20=EC=BD=94=EB=93=9C=EB=A5=BC=20?= =?UTF-8?q?=EB=B3=B4=EB=82=B8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 앱에서 진행 중인 참가 대회를 고르고, 오늘 코드가 있으면 그 값을 인증 업로드에 실어 순위에 들어가게 한다. Co-authored-by: park gun hyung --- .../src/tournaments/day-code.service.spec.ts | 22 ++++- .../src/tournaments/tournaments.controller.ts | 9 ++ .../src/tournaments/tournaments.service.ts | 30 +++++++ fishing/apps/mobile/app/(tabs)/capture.tsx | 87 +++++++++++++++++-- fishing/apps/mobile/src/lib/api.ts | 16 +++- 5 files changed, 152 insertions(+), 12 deletions(-) diff --git a/fishing/apps/api/src/tournaments/day-code.service.spec.ts b/fishing/apps/api/src/tournaments/day-code.service.spec.ts index 10ccaf0..1c9c489 100644 --- a/fishing/apps/api/src/tournaments/day-code.service.spec.ts +++ b/fishing/apps/api/src/tournaments/day-code.service.spec.ts @@ -6,8 +6,8 @@ describe('TournamentsService 일일 코드', () => { const prisma = { user: { findUnique: jest.fn() }, tournament: { findUnique: jest.fn() }, - tournamentEntry: { findUnique: jest.fn() }, - tournamentDayCode: { findUnique: jest.fn(), upsert: jest.fn() }, + tournamentEntry: { findUnique: jest.fn(), findMany: jest.fn() }, + tournamentDayCode: { findUnique: jest.fn(), upsert: jest.fn(), findMany: jest.fn() }, }; const service = new TournamentsService(prisma as never); @@ -98,6 +98,24 @@ describe('TournamentsService 일일 코드', () => { expect(prisma.tournamentDayCode.findUnique).not.toHaveBeenCalled(); }); + it('참가 중인 진행 대회는 오늘 코드 값 없이 여부만 알려 준다', async () => { + prisma.tournamentEntry.findMany.mockResolvedValue([ + { tournamentId: 'tour-1', tournament: { id: 'tour-1', title: '거제', region: '경남' } }, + { tournamentId: 'tour-2', tournament: { id: 'tour-2', title: '춘천', region: null } }, + ]); + prisma.tournamentDayCode.findMany.mockResolvedValue([{ tournamentId: 'tour-1' }]); + + await expect(service.listMyActive('user-1', now)).resolves.toEqual([ + { tournamentId: 'tour-1', title: '거제', region: '경남', codeDate: '2026-10-20', hasCode: true }, + { tournamentId: 'tour-2', title: '춘천', region: null, codeDate: '2026-10-20', hasCode: false }, + ]); + expect(prisma.tournamentEntry.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ userId: 'user-1', disqualified: false }), + }), + ); + }); + it('다른 코드는 거절한다', async () => { prisma.tournamentEntry.findUnique.mockResolvedValue({ paymentStatus: 'paid' }); prisma.tournamentDayCode.findUnique.mockResolvedValue({ code: 'LAKE1' }); diff --git a/fishing/apps/api/src/tournaments/tournaments.controller.ts b/fishing/apps/api/src/tournaments/tournaments.controller.ts index 0ff3b25..d1d6866 100644 --- a/fishing/apps/api/src/tournaments/tournaments.controller.ts +++ b/fishing/apps/api/src/tournaments/tournaments.controller.ts @@ -179,6 +179,15 @@ export class TournamentsController { return `\uFEFF${csv}`; } + @Get('me/active') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: '내가 참가 중인 진행 대회. 오늘 코드 값은 포함하지 않음' }) + async listMyActive(@CurrentUser() user: any) { + const result = await this.tournamentsService.listMyActive(user.id); + return { success: true, data: result }; + } + @Get(':id') @ApiOperation({ summary: '대회 상세' }) diff --git a/fishing/apps/api/src/tournaments/tournaments.service.ts b/fishing/apps/api/src/tournaments/tournaments.service.ts index c896fca..246daea 100644 --- a/fishing/apps/api/src/tournaments/tournaments.service.ts +++ b/fishing/apps/api/src/tournaments/tournaments.service.ts @@ -84,6 +84,36 @@ export class TournamentsService { }); } + async listMyActive(userId: string, now = new Date()) { + const entries = await this.prisma.tournamentEntry.findMany({ + where: { + userId, + disqualified: false, + paymentStatus: { in: ['free', 'paid'] }, + tournament: { status: 'active', startAt: { lte: now }, endAt: { gte: now } }, + }, + include: { + tournament: { select: { id: true, title: true, region: true } }, + }, + orderBy: { joinedAt: 'asc' }, + }); + if (!entries.length) return []; + + const codeDate = seoulDateKey(now); + const codes = await this.prisma.tournamentDayCode.findMany({ + where: { codeDate, tournamentId: { in: entries.map((entry) => entry.tournamentId) } }, + select: { tournamentId: true }, + }); + const withCode = new Set(codes.map((row) => row.tournamentId)); + return entries.map((entry) => ({ + tournamentId: entry.tournamentId, + title: entry.tournament.title, + region: entry.tournament.region, + codeDate, + hasCode: withCode.has(entry.tournamentId), + })); + } + async getRanking(tournamentId: string) { const tournament = await this.prisma.tournament.findUnique({ where: { id: tournamentId } }); if (!tournament) throw new NotFoundException('대회를 찾을 수 없습니다.'); diff --git a/fishing/apps/mobile/app/(tabs)/capture.tsx b/fishing/apps/mobile/app/(tabs)/capture.tsx index 8ce55f5..ff1cb24 100644 --- a/fishing/apps/mobile/app/(tabs)/capture.tsx +++ b/fishing/apps/mobile/app/(tabs)/capture.tsx @@ -1,4 +1,5 @@ import { useRef, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; import { View, Text, @@ -17,9 +18,17 @@ import CaptureOverlay from '@/components/CaptureOverlay'; import CaptureToolbar from '@/components/capture/CaptureToolbar'; import HeroBackground from '@/components/HeroBackground'; import { useDeviceLevel } from '@/hooks/useDeviceLevel'; -import { uploadCertifiedCatch } from '@/lib/api'; +import { api, uploadCertifiedCatch } from '@/lib/api'; +import { useAuthStore } from '@/store/auth.store'; import { colors } from '@/theme/colors'; +type ActiveTournament = { + tournamentId: string; + title: string; + region?: string | null; + hasCode: boolean; +}; + export default function CaptureScreen() { const cameraRef = useRef(null); const insets = useSafeAreaInsets(); @@ -27,7 +36,16 @@ export default function CaptureScreen() { const [uploading, setUploading] = useState(false); const [locationName, setLocationName] = useState(''); const [memo, setMemo] = useState(''); + const [tournamentId, setTournamentId] = useState(null); + const [dayCode, setDayCode] = useState(''); const [previewUri, setPreviewUri] = useState(null); + const isLoggedIn = useAuthStore((state) => state.isLoggedIn); + const { data: activeTournaments } = useQuery({ + queryKey: ['my-active-tournaments'], + queryFn: async () => (await api.get('/tournaments/me/active')).data.data as ActiveTournament[], + enabled: isLoggedIn, + }); + const selectedTournament = activeTournaments?.find((item) => item.tournamentId === tournamentId); const [torch, setTorch] = useState(false); const [flash, setFlash] = useState('auto'); @@ -81,13 +99,18 @@ export default function CaptureScreen() { async function handleUpload() { if (!previewUri || uploading) return; + if (selectedTournament?.hasCode && dayCode.trim().length < 4) { + Alert.alert('대회 코드', '현장에 안내된 오늘 코드를 입력해 주세요.'); + return; + } setUploading(true); try { - const res = await uploadCertifiedCatch( - previewUri, - locationName.trim() || undefined, - memo.trim() || undefined, - ); + const res = await uploadCertifiedCatch(previewUri, { + locationName: locationName.trim() || undefined, + memo: memo.trim() || undefined, + tournamentId: selectedTournament?.hasCode ? selectedTournament.tournamentId : undefined, + dayCode: selectedTournament?.hasCode ? dayCode.trim() : undefined, + }); const { message, status } = res.data.data; setPreviewUri(null); Alert.alert(status === 'approved' ? '인증 완료' : '업로드 완료', message); @@ -156,6 +179,36 @@ export default function CaptureScreen() { value={memo} onChangeText={setMemo} /> + {!!activeTournaments?.length && ( + + setTournamentId(null)} + > + 대회 없음 + + {activeTournaments.map((item) => ( + setTournamentId(item.tournamentId)} + > + {item.title} + + ))} + + )} + {selectedTournament?.hasCode && ( + + )} 촬영 @@ -168,6 +221,17 @@ export default function CaptureScreen() { ) : null} 촬영 미리보기 사진을 확인한 뒤 인증 업로드하거나 다시 촬영하세요. + {selectedTournament?.hasCode && ( + + )}