From 4501aea4a696e00ca22e7f7b7bcea3c03c0d374c Mon Sep 17 00:00:00 2001 From: rjsgud49 Date: Tue, 20 Oct 2026 07:17:41 +0000 Subject: [PATCH] =?UTF-8?q?feat(tournaments):=20=EB=82=9A=EC=8B=9C?= =?UTF-8?q?=EC=A0=90=20=EC=9A=B4=EC=98=81=EC=9E=90=EA=B0=80=20=EB=AC=B4?= =?UTF-8?q?=EB=A3=8C=20=EB=8C=80=ED=9A=8C=EB=A5=BC=20=EB=A7=8C=EB=93=A0?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 운영자 역할을 추가하고, 어종·체장·지역·후원 상을 담은 대회는 참가비 없이만 열리게 한다. Co-authored-by: park gunhyung --- .../migration.sql | 12 ++ fishing/apps/api/prisma/schema.prisma | 10 +- fishing/apps/api/src/admin/admin.service.ts | 2 +- .../apps/api/src/admin/dto/update-user.dto.ts | 4 +- .../dto/organizer-tournament.dto.ts | 76 ++++++++++ .../src/tournaments/organizer.service.spec.ts | 75 ++++++++++ .../src/tournaments/tournaments.controller.ts | 28 ++++ .../src/tournaments/tournaments.service.ts | 109 +++++++++++++- fishing/apps/web/src/app/admin/users/page.tsx | 30 ++-- .../web/src/app/tournament/organize/page.tsx | 133 ++++++++++++++++++ fishing/apps/web/src/components/Navbar.tsx | 9 ++ 11 files changed, 461 insertions(+), 27 deletions(-) create mode 100644 fishing/apps/api/prisma/migrations/20261020143000_tournament_organizer/migration.sql create mode 100644 fishing/apps/api/src/tournaments/dto/organizer-tournament.dto.ts create mode 100644 fishing/apps/api/src/tournaments/organizer.service.spec.ts create mode 100644 fishing/apps/web/src/app/tournament/organize/page.tsx diff --git a/fishing/apps/api/prisma/migrations/20261020143000_tournament_organizer/migration.sql b/fishing/apps/api/prisma/migrations/20261020143000_tournament_organizer/migration.sql new file mode 100644 index 0000000..f7619ef --- /dev/null +++ b/fishing/apps/api/prisma/migrations/20261020143000_tournament_organizer/migration.sql @@ -0,0 +1,12 @@ +ALTER TABLE "tournaments" +ADD COLUMN "organizer_id" TEXT, +ADD COLUMN "region" VARCHAR(80), +ADD COLUMN "min_length_cm" DECIMAL(5,1), +ADD COLUMN "max_length_cm" DECIMAL(5,1), +ADD COLUMN "sponsor_name" VARCHAR(80); + +CREATE INDEX "tournaments_organizer_id_idx" ON "tournaments"("organizer_id"); + +ALTER TABLE "tournaments" +ADD CONSTRAINT "tournaments_organizer_id_fkey" +FOREIGN KEY ("organizer_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/fishing/apps/api/prisma/schema.prisma b/fishing/apps/api/prisma/schema.prisma index f74a916..54fb1ee 100644 --- a/fishing/apps/api/prisma/schema.prisma +++ b/fishing/apps/api/prisma/schema.prisma @@ -19,7 +19,7 @@ model User { activityRegion String? @map("activity_region") // 주 활동 지역 fishingCategory String? @map("fishing_category") // freshwater | saltwater | both featuredCatchIds String[] @default([]) @map("featured_catch_ids") - role String @default("user") // user | admin + role String @default("user") // user | admin | organizer refreshTokenHash String? @map("refresh_token_hash") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -30,6 +30,7 @@ model User { posts Post[] comments Comment[] tournamentEntries TournamentEntry[] + organizedTournaments Tournament[] @relation("OrganizedTournaments") catchVotes CatchVote[] contentReports ContentReport[] userFeedbacks UserFeedback[] @@ -332,13 +333,20 @@ model Tournament { status String @default("upcoming") // upcoming | active | closed | finished bannerUrl String? @map("banner_url") rules String? + organizerId String? @map("organizer_id") + region String? @db.VarChar(80) + minLengthCm Decimal? @map("min_length_cm") @db.Decimal(5, 1) + maxLengthCm Decimal? @map("max_length_cm") @db.Decimal(5, 1) + sponsorName String? @map("sponsor_name") @db.VarChar(80) createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") entries TournamentEntry[] dayCodes TournamentDayCode[] dayCodeClaims Catch[] + organizer User? @relation("OrganizedTournaments", fields: [organizerId], references: [id], onDelete: SetNull) + @@index([organizerId]) @@map("tournaments") } diff --git a/fishing/apps/api/src/admin/admin.service.ts b/fishing/apps/api/src/admin/admin.service.ts index 28dc87f..8ca0dc2 100644 --- a/fishing/apps/api/src/admin/admin.service.ts +++ b/fishing/apps/api/src/admin/admin.service.ts @@ -538,7 +538,7 @@ export class AdminService { const user = await this.prisma.user.findUnique({ where: { id: userId } }); if (!user) throw new NotFoundException('사용자를 찾을 수 없습니다.'); - if (dto.role === 'user' && user.role === 'admin') { + if (dto.role && dto.role !== 'admin' && user.role === 'admin') { const adminCount = await this.prisma.user.count({ where: { role: 'admin', deletedAt: null, id: { not: userId } }, }); diff --git a/fishing/apps/api/src/admin/dto/update-user.dto.ts b/fishing/apps/api/src/admin/dto/update-user.dto.ts index baabe2d..1662114 100644 --- a/fishing/apps/api/src/admin/dto/update-user.dto.ts +++ b/fishing/apps/api/src/admin/dto/update-user.dto.ts @@ -2,8 +2,8 @@ import { IsIn, IsOptional } from 'class-validator'; export class UpdateUserDto { @IsOptional() - @IsIn(['user', 'admin']) - role?: 'user' | 'admin'; + @IsIn(['user', 'admin', 'organizer']) + role?: 'user' | 'admin' | 'organizer'; /** active = 복구, suspended = 정지(soft delete) */ @IsOptional() diff --git a/fishing/apps/api/src/tournaments/dto/organizer-tournament.dto.ts b/fishing/apps/api/src/tournaments/dto/organizer-tournament.dto.ts new file mode 100644 index 0000000..67e1cf3 --- /dev/null +++ b/fishing/apps/api/src/tournaments/dto/organizer-tournament.dto.ts @@ -0,0 +1,76 @@ +import { Type } from 'class-transformer'; +import { IsIn, IsInt, IsNumber, IsOptional, IsString, MaxLength, Min, MinLength } from 'class-validator'; + +export class OrganizerTournamentDto { + @IsString() + @MinLength(2) + @MaxLength(80) + title: string; + + @IsString() + @MinLength(2) + @MaxLength(2000) + description: string; + + @IsIn(['freshwater', 'saltwater', 'all']) + category: 'freshwater' | 'saltwater' | 'all'; + + @IsOptional() + @Type(() => Number) + @IsInt() + fishSpeciesId?: number; + + @IsOptional() + @IsString() + @MaxLength(80) + region?: string; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(0) + minLengthCm?: number; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(0) + maxLengthCm?: number; + + @IsOptional() + @IsString() + @MaxLength(200) + prize?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + prizeAmount?: number; + + @IsOptional() + @IsString() + @MaxLength(80) + sponsorName?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + maxEntries?: number; + + @IsString() + startAt: string; + + @IsString() + endAt: string; + + @IsOptional() + @IsString() + @MaxLength(2000) + rules?: string; + + @IsOptional() + @IsIn(['upcoming', 'active', 'closed', 'finished']) + status?: 'upcoming' | 'active' | 'closed' | 'finished'; +} diff --git a/fishing/apps/api/src/tournaments/organizer.service.spec.ts b/fishing/apps/api/src/tournaments/organizer.service.spec.ts new file mode 100644 index 0000000..1a46d31 --- /dev/null +++ b/fishing/apps/api/src/tournaments/organizer.service.spec.ts @@ -0,0 +1,75 @@ +import { BadRequestException, ForbiddenException } 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() }, + }; + const service = new TournamentsService(prisma as never); + const dto = { + title: '거제 감성돔', + description: '낚시점 후원 대회', + category: 'saltwater', + region: '경남 거제', + minLengthCm: 25, + maxLengthCm: 80, + prize: '상품권', + prizeAmount: 100000, + sponsorName: '거제낚시점', + startAt: '2026-11-01T00:00:00.000Z', + endAt: '2026-11-02T00:00:00.000Z', + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('운영자는 무료 대회만 만들고 후원 상을 저장한다', async () => { + prisma.user.findUnique.mockResolvedValue({ id: 'org-1', role: 'organizer' }); + prisma.tournament.create.mockImplementation(async ({ data }) => data); + + const created = await service.createForOrganizer('org-1', dto); + + expect(created).toEqual(expect.objectContaining({ + organizerId: 'org-1', + isFree: true, + entryFee: 0, + sponsorName: '거제낚시점', + region: '경남 거제', + minLengthCm: 25, + prize: '상품권', + })); + }); + + it('참가비가 있으면 거절한다', async () => { + prisma.user.findUnique.mockResolvedValue({ id: 'org-1', role: 'organizer' }); + await expect( + service.createForOrganizer('org-1', { ...dto, entryFee: 5000 }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('일반 회원은 대회를 열 수 없다', async () => { + prisma.user.findUnique.mockResolvedValue({ id: 'user-1', role: 'user' }); + await expect(service.createForOrganizer('user-1', dto)).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.tournamentDayCode.upsert.mockResolvedValue({ code: 'SHOP1' }); + + await service.setTodayDayCode('org-1', 'tour-1', 'shop1', new Date('2026-10-20T03:00:00.000Z')); + + expect(prisma.tournamentDayCode.upsert).toHaveBeenCalled(); + }); + + it('다른 운영자의 대회 코드는 등록하지 못한다', async () => { + prisma.user.findUnique.mockResolvedValue({ id: 'org-2', role: 'organizer' }); + prisma.tournament.findUnique.mockResolvedValue({ id: 'tour-1', organizerId: 'org-1' }); + await expect( + service.setTodayDayCode('org-2', 'tour-1', 'SHOP1', new Date('2026-10-20T03:00:00.000Z')), + ).rejects.toBeInstanceOf(ForbiddenException); + }); +}); diff --git a/fishing/apps/api/src/tournaments/tournaments.controller.ts b/fishing/apps/api/src/tournaments/tournaments.controller.ts index e10a835..3b00eef 100644 --- a/fishing/apps/api/src/tournaments/tournaments.controller.ts +++ b/fishing/apps/api/src/tournaments/tournaments.controller.ts @@ -8,6 +8,7 @@ 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 { createImageUploadInterceptor, saveUploadedFile } from '../common/upload/upload.config'; import { ImageMagicInterceptor } from '../common/upload/image-magic.interceptor'; @@ -72,6 +73,33 @@ export class TournamentsController { + @Get('organized') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: '내가 운영하는 대회' }) + async listOrganized(@CurrentUser() user: any) { + const result = await this.tournamentsService.listOrganized(user.id); + return { success: true, data: result }; + } + + @Post('organized') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: '낚시점·포인트 운영자가 무료 대회 생성' }) + async createOrganized(@CurrentUser() user: any, @Body() body: OrganizerTournamentDto) { + const result = await this.tournamentsService.createForOrganizer(user.id, body); + return { success: true, data: result }; + } + + @Patch('organized/:id') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: '운영 대회 수정. 참가비는 항상 무료' }) + async updateOrganized(@Param('id') id: string, @CurrentUser() user: any, @Body() body: OrganizerTournamentDto) { + const result = await this.tournamentsService.updateForOrganizer(user.id, id, body); + 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 c7459ca..197a84a 100644 --- a/fishing/apps/api/src/tournaments/tournaments.service.ts +++ b/fishing/apps/api/src/tournaments/tournaments.service.ts @@ -143,16 +143,17 @@ export class TournamentsService { return { codeDate, code: row?.code ?? null }; } - async setTodayDayCode(adminId: string, tournamentId: string, rawCode: string, now = new Date()) { - const admin = await this.prisma.user.findUnique({ where: { id: adminId } }); - if (admin?.role !== 'admin') throw new ForbiddenException('관리자만 대회 코드를 등록할 수 있습니다.'); + async setTodayDayCode(actorId: string, tournamentId: string, rawCode: string, now = new Date()) { + const actor = await this.prisma.user.findUnique({ where: { id: actorId } }); + const tournament = await this.prisma.tournament.findUnique({ where: { id: tournamentId } }); + if (!tournament) throw new NotFoundException('대회를 찾을 수 없습니다.'); + const isAdmin = actor?.role === 'admin'; + const isOwner = actor?.role === 'organizer' && tournament.organizerId === actorId; + if (!isAdmin && !isOwner) throw new ForbiddenException('이 대회의 코드를 등록할 수 없습니다.'); const code = normalizeDayCode(rawCode); if (!code) throw new BadRequestException('대회 코드는 4~12자의 영문 또는 숫자여야 합니다.'); - const tournament = await this.prisma.tournament.findUnique({ where: { id: tournamentId } }); - if (!tournament) throw new NotFoundException('대회를 찾을 수 없습니다.'); - const codeDate = seoulDateKey(now); return this.prisma.tournamentDayCode.upsert({ where: { tournamentId_codeDate: { tournamentId, codeDate } }, @@ -188,4 +189,100 @@ export class TournamentsService { return { claimedTournamentId: tournamentId, claimedDayCode: code, dayCodeStatus: 'pending' }; } + + async listOrganized(userId: string) { + await this.assertOrganizer(userId); + return this.prisma.tournament.findMany({ + where: { organizerId: userId }, + include: { _count: { select: { entries: true } } }, + orderBy: { startAt: 'desc' }, + }); + } + + async createForOrganizer(userId: string, dto: OrganizerTournamentWrite) { + await this.assertOrganizer(userId); + this.assertFreeEntry(dto); + const data = this.organizerTournamentData(dto); + return this.prisma.tournament.create({ + data: { ...data, organizerId: userId, isFree: true, entryFee: 0 }, + }); + } + + async updateForOrganizer(userId: string, id: string, dto: OrganizerTournamentWrite) { + const actor = await this.assertOrganizer(userId); + const tournament = await this.prisma.tournament.findUnique({ where: { id } }); + if (!tournament) throw new NotFoundException('대회를 찾을 수 없습니다.'); + if (actor.role !== 'admin' && tournament.organizerId !== userId) { + throw new ForbiddenException('본인이 연 대회만 수정할 수 있습니다.'); + } + this.assertFreeEntry(dto); + return this.prisma.tournament.update({ + where: { id }, + data: { ...this.organizerTournamentData(dto), isFree: true, entryFee: 0 }, + }); + } + + private async assertOrganizer(userId: string) { + const user = await this.prisma.user.findUnique({ where: { id: userId } }); + if (!user || (user.role !== 'organizer' && user.role !== 'admin')) { + throw new ForbiddenException('대회 운영자만 사용할 수 있습니다.'); + } + return user; + } + + private assertFreeEntry(dto: { entryFee?: number; isFree?: boolean }) { + if ((dto.entryFee ?? 0) > 0 || dto.isFree === false) { + throw new BadRequestException('참가비는 아직 받을 수 없습니다. 상은 후원으로 안내해 주세요.'); + } + } + + private organizerTournamentData(dto: OrganizerTournamentWrite) { + const startAt = new Date(dto.startAt); + const endAt = new Date(dto.endAt); + if (Number.isNaN(startAt.getTime()) || Number.isNaN(endAt.getTime()) || endAt <= startAt) { + throw new BadRequestException('대회 기간이 올바르지 않습니다.'); + } + const minLengthCm = dto.minLengthCm ?? null; + const maxLengthCm = dto.maxLengthCm ?? null; + if (minLengthCm != null && maxLengthCm != null && minLengthCm > maxLengthCm) { + throw new BadRequestException('최소 체장이 최대 체장보다 클 수 없습니다.'); + } + return { + title: dto.title.trim(), + description: dto.description.trim(), + category: dto.category, + fishSpeciesId: dto.fishSpeciesId ?? null, + region: dto.region?.trim() || null, + minLengthCm, + maxLengthCm, + prize: dto.prize?.trim() || null, + prizeAmount: dto.prizeAmount ?? null, + sponsorName: dto.sponsorName?.trim() || null, + maxEntries: dto.maxEntries ?? null, + startAt, + endAt, + rules: dto.rules?.trim() || null, + status: dto.status || 'upcoming', + }; + } } + +type OrganizerTournamentWrite = { + title: string; + description: string; + category: string; + fishSpeciesId?: number | null; + region?: string | null; + minLengthCm?: number | null; + maxLengthCm?: number | null; + prize?: string | null; + prizeAmount?: number | null; + sponsorName?: string | null; + maxEntries?: number | null; + startAt: string; + endAt: string; + rules?: string | null; + status?: string; + entryFee?: number; + isFree?: boolean; +}; diff --git a/fishing/apps/web/src/app/admin/users/page.tsx b/fishing/apps/web/src/app/admin/users/page.tsx index 16781d4..0680fb2 100644 --- a/fishing/apps/web/src/app/admin/users/page.tsx +++ b/fishing/apps/web/src/app/admin/users/page.tsx @@ -9,6 +9,7 @@ import { formatTimeAgo } from '@/lib/utils'; const ROLE_FILTERS = [ { value: 'all', label: '전체 역할' }, { value: 'user', label: '일반' }, + { value: 'organizer', label: '대회 운영' }, { value: 'admin', label: '관리자' }, ]; @@ -202,24 +203,19 @@ export default function AdminUsersPage() {
{detail.user._count.userFeedbacks}피드백
+
- {detail.user.role === 'admin' ? ( - - ) : ( - - )} {detail.user.deletedAt ? (