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
Original file line number Diff line number Diff line change
@@ -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;
10 changes: 9 additions & 1 deletion fishing/apps/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -30,6 +30,7 @@ model User {
posts Post[]
comments Comment[]
tournamentEntries TournamentEntry[]
organizedTournaments Tournament[] @relation("OrganizedTournaments")
catchVotes CatchVote[]
contentReports ContentReport[]
userFeedbacks UserFeedback[]
Expand Down Expand Up @@ -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")
}

Expand Down
2 changes: 1 addition & 1 deletion fishing/apps/api/src/admin/admin.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } },
});
Expand Down
4 changes: 2 additions & 2 deletions fishing/apps/api/src/admin/dto/update-user.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
76 changes: 76 additions & 0 deletions fishing/apps/api/src/tournaments/dto/organizer-tournament.dto.ts
Original file line number Diff line number Diff line change
@@ -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';
}
75 changes: 75 additions & 0 deletions fishing/apps/api/src/tournaments/organizer.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
28 changes: 28 additions & 0 deletions fishing/apps/api/src/tournaments/tournaments.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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: '대회 상세' })
Expand Down
109 changes: 103 additions & 6 deletions fishing/apps/api/src/tournaments/tournaments.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } },
Expand Down Expand Up @@ -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;
};
Loading
Loading