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
6 changes: 6 additions & 0 deletions fishing/apps/api/src/tournaments/dto/organizer-status.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { IsIn } from 'class-validator';

export class OrganizerStatusDto {
@IsIn(['upcoming', 'active', 'closed', 'finished'])
status: 'upcoming' | 'active' | 'closed' | 'finished';
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { IsIn } from 'class-validator';

export class ReviewDayCodeClaimDto {
@IsIn(['accepted', 'rejected'])
status: 'accepted' | 'rejected';
}
55 changes: 54 additions & 1 deletion fishing/apps/api/src/tournaments/organizer.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
import { TournamentsService } from './tournaments.service';

describe('TournamentsService 운영자 대회', () => {
const prisma = {
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 = {
Expand Down Expand Up @@ -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();
});
});
34 changes: 34 additions & 0 deletions fishing/apps/api/src/tournaments/tournaments.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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()
Expand Down
59 changes: 59 additions & 0 deletions fishing/apps/api/src/tournaments/tournaments.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading
Loading