Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions fishing/apps/api/src/tournaments/day-code.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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' });
Expand Down
9 changes: 9 additions & 0 deletions fishing/apps/api/src/tournaments/tournaments.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '대회 상세' })
Expand Down
30 changes: 30 additions & 0 deletions fishing/apps/api/src/tournaments/tournaments.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('대회를 찾을 수 없습니다.');
Expand Down
87 changes: 81 additions & 6 deletions fishing/apps/mobile/app/(tabs)/capture.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import {
View,
Text,
Expand All @@ -17,17 +18,34 @@ 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<CameraView>(null);
const insets = useSafeAreaInsets();
const [permission, requestPermission] = useCameraPermissions();
const [uploading, setUploading] = useState(false);
const [locationName, setLocationName] = useState('');
const [memo, setMemo] = useState('');
const [tournamentId, setTournamentId] = useState<string | null>(null);
const [dayCode, setDayCode] = useState('');
const [previewUri, setPreviewUri] = useState<string | null>(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<FlashMode>('auto');
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -156,6 +179,36 @@ export default function CaptureScreen() {
value={memo}
onChangeText={setMemo}
/>
{!!activeTournaments?.length && (
<View style={styles.tournamentRow}>
<Pressable
style={[styles.tournamentChip, !tournamentId && styles.tournamentChipOn]}
onPress={() => setTournamentId(null)}
>
<Text style={styles.tournamentChipText}>대회 없음</Text>
</Pressable>
{activeTournaments.map((item) => (
<Pressable
key={item.tournamentId}
style={[styles.tournamentChip, tournamentId === item.tournamentId && styles.tournamentChipOn]}
onPress={() => setTournamentId(item.tournamentId)}
>
<Text style={styles.tournamentChipText}>{item.title}</Text>
</Pressable>
))}
</View>
)}
{selectedTournament?.hasCode && (
<TextInput
style={styles.input}
placeholder="오늘 대회 코드"
placeholderTextColor={colors.textMuted}
autoCapitalize="characters"
maxLength={12}
value={dayCode}
onChangeText={setDayCode}
/>
)}
<Pressable style={styles.shutter} onPress={handleCapture} disabled={uploading}>
<Text style={styles.shutterText}>촬영</Text>
</Pressable>
Expand All @@ -168,6 +221,17 @@ export default function CaptureScreen() {
) : null}
<Text style={styles.previewTitle}>촬영 미리보기</Text>
<Text style={styles.previewHint}>사진을 확인한 뒤 인증 업로드하거나 다시 촬영하세요.</Text>
{selectedTournament?.hasCode && (
<TextInput
style={[styles.input, { marginTop: 16 }]}
placeholder="오늘 대회 코드"
placeholderTextColor={colors.textMuted}
autoCapitalize="characters"
maxLength={12}
value={dayCode}
onChangeText={setDayCode}
/>
)}
<View style={styles.previewActions}>
<Pressable
style={styles.retakeBtn}
Expand Down Expand Up @@ -264,6 +328,17 @@ const styles = StyleSheet.create({
borderColor: colors.border,
fontSize: 15,
},
tournamentRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
tournamentChip: {
borderWidth: 1,
borderColor: colors.border,
borderRadius: 8,
paddingHorizontal: 10,
paddingVertical: 8,
backgroundColor: colors.surfaceMuted,
},
tournamentChipOn: { borderColor: colors.oceanBright, backgroundColor: colors.oceanLight },
tournamentChipText: { color: colors.textPrimary, fontWeight: '700', fontSize: 13 },
shutter: {
backgroundColor: colors.oceanBright,
borderRadius: 8,
Expand Down
16 changes: 12 additions & 4 deletions fishing/apps/mobile/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,17 +83,25 @@ api.interceptors.response.use(

export async function uploadCertifiedCatch(
uri: string,
locationName?: string,
memo?: string,
options?: {
locationName?: string;
memo?: string;
tournamentId?: string;
dayCode?: string;
},
) {
const form = new FormData();
form.append('image', {
uri,
name: `catch-${Date.now()}.jpg`,
type: 'image/jpeg',
} as unknown as Blob);
if (locationName) form.append('locationName', locationName);
if (memo) form.append('memo', memo);
if (options?.locationName) form.append('locationName', options.locationName);
if (options?.memo) form.append('memo', options.memo);
if (options?.tournamentId && options?.dayCode) {
form.append('tournamentId', options.tournamentId);
form.append('dayCode', options.dayCode);
}

const session = await api.post('/catches/capture-sessions');
form.append('captureToken', session.data.data.captureToken);
Expand Down
Loading