From 667de718029ca4e4e8a0db4213fcdb8475f80df2 Mon Sep 17 00:00:00 2001
From: rjsgud49
Date: Tue, 20 Oct 2026 07:10:44 +0000
Subject: [PATCH] =?UTF-8?q?feat(tournaments):=20=EB=8C=80=ED=9A=8C=20?=
=?UTF-8?q?=EC=9D=BC=EC=9D=BC=20=EC=BD=94=EB=93=9C=EB=A5=BC=20=EC=A0=80?=
=?UTF-8?q?=EC=9E=A5=ED=95=98=EA=B3=A0=20=EC=88=98=EB=8F=99=20=ED=99=95?=
=?UTF-8?q?=EC=9D=B8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
서울 날짜 코드를 등록하고, 참가자가 인증 사진과 함께 제출하면 관리자가 확인하거나 거절한다. 거절해도 랭킹은 자동으로 바꾸지 않는다.
Co-authored-by: park gunhyung
---
.../migration.sql | 25 +++++++
fishing/apps/api/prisma/schema.prisma | 27 +++++--
.../apps/api/src/admin/admin-day-code.spec.ts | 35 ++++++++++
.../apps/api/src/admin/admin.controller.ts | 8 +++
fishing/apps/api/src/admin/admin.service.ts | 14 ++++
.../api/src/admin/dto/review-day-code.dto.ts | 6 ++
.../apps/api/src/catches/catches.module.ts | 3 +-
.../api/src/catches/catches.service.spec.ts | 22 +++++-
.../apps/api/src/catches/catches.service.ts | 15 ++++
.../src/tournaments/day-code.service.spec.ts | 70 +++++++++++++++++++
.../api/src/tournaments/day-code.util.spec.ts | 15 ++++
.../apps/api/src/tournaments/day-code.util.ts | 16 +++++
.../src/tournaments/dto/set-day-code.dto.ts | 8 +++
.../src/tournaments/tournaments.controller.ts | 17 +++++
.../src/tournaments/tournaments.service.ts | 69 ++++++++++++++++++
.../apps/web/src/app/admin/catches/page.tsx | 17 +++++
.../web/src/app/admin/tournaments/page.tsx | 19 +++++
17 files changed, 379 insertions(+), 7 deletions(-)
create mode 100644 fishing/apps/api/prisma/migrations/20261020133000_tournament_day_codes/migration.sql
create mode 100644 fishing/apps/api/src/admin/admin-day-code.spec.ts
create mode 100644 fishing/apps/api/src/admin/dto/review-day-code.dto.ts
create mode 100644 fishing/apps/api/src/tournaments/day-code.service.spec.ts
create mode 100644 fishing/apps/api/src/tournaments/day-code.util.spec.ts
create mode 100644 fishing/apps/api/src/tournaments/day-code.util.ts
create mode 100644 fishing/apps/api/src/tournaments/dto/set-day-code.dto.ts
diff --git a/fishing/apps/api/prisma/migrations/20261020133000_tournament_day_codes/migration.sql b/fishing/apps/api/prisma/migrations/20261020133000_tournament_day_codes/migration.sql
new file mode 100644
index 0000000..bc756d3
--- /dev/null
+++ b/fishing/apps/api/prisma/migrations/20261020133000_tournament_day_codes/migration.sql
@@ -0,0 +1,25 @@
+ALTER TABLE "catches"
+ADD COLUMN "claimed_tournament_id" TEXT,
+ADD COLUMN "claimed_day_code" VARCHAR(12),
+ADD COLUMN "day_code_status" VARCHAR(20);
+
+CREATE TABLE "tournament_day_codes" (
+ "id" TEXT NOT NULL,
+ "tournament_id" TEXT NOT NULL,
+ "code_date" VARCHAR(10) NOT NULL,
+ "code" VARCHAR(12) NOT NULL,
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "tournament_day_codes_pkey" PRIMARY KEY ("id")
+);
+
+CREATE UNIQUE INDEX "tournament_day_codes_tournament_id_code_date_key"
+ON "tournament_day_codes"("tournament_id", "code_date");
+
+ALTER TABLE "tournament_day_codes"
+ADD CONSTRAINT "tournament_day_codes_tournament_id_fkey"
+FOREIGN KEY ("tournament_id") REFERENCES "tournaments"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+ALTER TABLE "catches"
+ADD CONSTRAINT "catches_claimed_tournament_id_fkey"
+FOREIGN KEY ("claimed_tournament_id") REFERENCES "tournaments"("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 584cd15..39a8885 100644
--- a/fishing/apps/api/prisma/schema.prisma
+++ b/fishing/apps/api/prisma/schema.prisma
@@ -165,7 +165,10 @@ model Catch {
photoTakenAt DateTime? @map("photo_taken_at")
exifLat Decimal? @map("exif_lat") @db.Decimal(9, 6)
exifLng Decimal? @map("exif_lng") @db.Decimal(9, 6)
- integrityNote String? @map("integrity_note") @db.VarChar(80)
+ integrityNote String? @map("integrity_note") @db.VarChar(80)
+ claimedTournamentId String? @map("claimed_tournament_id")
+ claimedDayCode String? @map("claimed_day_code") @db.VarChar(12)
+ dayCodeStatus String? @map("day_code_status") @db.VarChar(20)
recordType String @default("certified") @map("record_type") // certified | personal
status String @default("pending")
memo String?
@@ -175,8 +178,9 @@ model Catch {
user User @relation(fields: [userId], references: [id])
fishSpecies FishSpecies? @relation(fields: [fishSpeciesId], references: [id])
- certification Certification?
- rankings Ranking[]
+ certification Certification?
+ claimedTournament Tournament? @relation(fields: [claimedTournamentId], references: [id], onDelete: SetNull)
+ rankings Ranking[]
votes CatchVote[]
posts Post[]
@@ -329,7 +333,9 @@ model Tournament {
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
- entries TournamentEntry[]
+ entries TournamentEntry[]
+ dayCodes TournamentDayCode[]
+ dayCodeClaims Catch[]
@@map("tournaments")
}
@@ -353,6 +359,19 @@ model TournamentEntry {
@@map("tournament_entries")
}
+model TournamentDayCode {
+ id String @id @default(uuid())
+ tournamentId String @map("tournament_id")
+ codeDate String @map("code_date") @db.VarChar(10)
+ code String @db.VarChar(12)
+ createdAt DateTime @default(now()) @map("created_at")
+
+ tournament Tournament @relation(fields: [tournamentId], references: [id], onDelete: Cascade)
+
+ @@unique([tournamentId, codeDate])
+ @@map("tournament_day_codes")
+}
+
model Announcement {
id String @id @default(uuid())
type String @default("notice") // notice | event
diff --git a/fishing/apps/api/src/admin/admin-day-code.spec.ts b/fishing/apps/api/src/admin/admin-day-code.spec.ts
new file mode 100644
index 0000000..3b68aef
--- /dev/null
+++ b/fishing/apps/api/src/admin/admin-day-code.spec.ts
@@ -0,0 +1,35 @@
+import { BadRequestException } from '@nestjs/common';
+import { AdminService } from './admin.service';
+
+describe('AdminService.reviewDayCode', () => {
+ const prisma = {
+ catch: {
+ findUnique: jest.fn(),
+ update: jest.fn(),
+ },
+ };
+ const service = new AdminService(prisma as never);
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('코드 확인은 상태만 바꾸고 랭킹 점수는 건드리지 않는다', async () => {
+ prisma.catch.findUnique.mockResolvedValue({ id: 'c1', claimedDayCode: 'LAKE1', rankScore: 40 });
+ prisma.catch.update.mockResolvedValue({});
+
+ await expect(service.reviewDayCode('c1', 'rejected')).resolves.toEqual({
+ catchId: 'c1',
+ dayCodeStatus: 'rejected',
+ });
+ expect(prisma.catch.update).toHaveBeenCalledWith({
+ where: { id: 'c1' },
+ data: { dayCodeStatus: 'rejected' },
+ });
+ });
+
+ it('코드가 없는 기록은 확인할 수 없다', async () => {
+ prisma.catch.findUnique.mockResolvedValue({ id: 'c1', claimedDayCode: null });
+ await expect(service.reviewDayCode('c1', 'accepted')).rejects.toBeInstanceOf(BadRequestException);
+ });
+});
\ No newline at end of file
diff --git a/fishing/apps/api/src/admin/admin.controller.ts b/fishing/apps/api/src/admin/admin.controller.ts
index 62a9f68..dfefc59 100644
--- a/fishing/apps/api/src/admin/admin.controller.ts
+++ b/fishing/apps/api/src/admin/admin.controller.ts
@@ -5,6 +5,7 @@ import { AdminGuard } from '../common/guards/admin.guard';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import { AdminService } from './admin.service';
import { ReviewCatchDto } from './dto/review-catch.dto';
+import { ReviewDayCodeDto } from './dto/review-day-code.dto';
import { UpsertAnnouncementDto } from './dto/upsert-announcement.dto';
import { UpdateFeedbackDto } from './dto/update-feedback.dto';
import { UpdateUserDto } from './dto/update-user.dto';
@@ -46,6 +47,13 @@ export class AdminController {
return { success: true, data };
}
+ @Patch('catches/:id/day-code')
+ @ApiOperation({ summary: '대회 일일 코드 수동 확인. 랭킹 상태는 바꾸지 않음' })
+ async reviewDayCode(@Param('id') id: string, @Body() dto: ReviewDayCodeDto) {
+ const data = await this.admin.reviewDayCode(id, dto.status);
+ return { success: true, data };
+ }
+
@Get('posts')
@ApiOperation({ summary: '게시글 관리 목록' })
async listPosts(@Query('page') page = '1', @Query('limit') limit = '20') {
diff --git a/fishing/apps/api/src/admin/admin.service.ts b/fishing/apps/api/src/admin/admin.service.ts
index 9e84658..5f1b5bf 100644
--- a/fishing/apps/api/src/admin/admin.service.ts
+++ b/fishing/apps/api/src/admin/admin.service.ts
@@ -186,6 +186,20 @@ export class AdminService {
return { action, targetType, targetId };
}
+ async reviewDayCode(catchId: string, status: 'accepted' | 'rejected') {
+ const catch_ = await this.prisma.catch.findUnique({
+ where: { id: catchId, deletedAt: null },
+ });
+ if (!catch_) throw new NotFoundException('기록을 찾을 수 없습니다.');
+ if (!catch_.claimedDayCode) throw new BadRequestException('제출된 대회 코드가 없습니다.');
+
+ await this.prisma.catch.update({
+ where: { id: catchId },
+ data: { dayCodeStatus: status },
+ });
+ return { catchId, dayCodeStatus: status };
+ }
+
async listCatches(status?: string, page = 1, limit = 20) {
const take = Math.min(50, Math.max(1, limit));
const skip = (Math.max(1, page) - 1) * take;
diff --git a/fishing/apps/api/src/admin/dto/review-day-code.dto.ts b/fishing/apps/api/src/admin/dto/review-day-code.dto.ts
new file mode 100644
index 0000000..9af072c
--- /dev/null
+++ b/fishing/apps/api/src/admin/dto/review-day-code.dto.ts
@@ -0,0 +1,6 @@
+import { IsIn } from 'class-validator';
+
+export class ReviewDayCodeDto {
+ @IsIn(['accepted', 'rejected'])
+ status: 'accepted' | 'rejected';
+}
diff --git a/fishing/apps/api/src/catches/catches.module.ts b/fishing/apps/api/src/catches/catches.module.ts
index 87c5dbc..be8af9a 100644
--- a/fishing/apps/api/src/catches/catches.module.ts
+++ b/fishing/apps/api/src/catches/catches.module.ts
@@ -3,9 +3,10 @@ import { CatchesService } from './catches.service';
import { CatchesController } from './catches.controller';
import { CaptureSessionService } from './capture-session.service';
import { AiModule } from '../ai/ai.module';
+import { TournamentsModule } from '../tournaments/tournaments.module';
@Module({
- imports: [AiModule],
+ imports: [AiModule, TournamentsModule],
controllers: [CatchesController],
providers: [CatchesService, CaptureSessionService],
})
diff --git a/fishing/apps/api/src/catches/catches.service.spec.ts b/fishing/apps/api/src/catches/catches.service.spec.ts
index 9f27a8a..0c7ebfb 100644
--- a/fishing/apps/api/src/catches/catches.service.spec.ts
+++ b/fishing/apps/api/src/catches/catches.service.spec.ts
@@ -1,4 +1,4 @@
-import { ConflictException, ServiceUnavailableException } from '@nestjs/common';
+import { BadRequestException, ConflictException, ServiceUnavailableException } from '@nestjs/common';
import { CatchesService } from './catches.service';
import { removeUploadedFile } from '../common/upload/image-integrity.util';
import { computePerceptualHash } from '../common/upload/image-phash.util';
@@ -32,7 +32,14 @@ describe('CatchesService 이미지 해시', () => {
},
};
const aiService = { analyze: jest.fn() };
- const service = new CatchesService(prisma as never, aiService as never);
+ const tournaments = {
+ resolveDayCodeClaim: jest.fn().mockResolvedValue({
+ claimedTournamentId: null,
+ claimedDayCode: null,
+ dayCodeStatus: null,
+ }),
+ };
+ const service = new CatchesService(prisma as never, aiService as never, tournaments as never);
const file = { path: '/tmp/fish.jpg', filename: 'catch-1.jpg' } as Express.Multer.File;
beforeEach(() => {
@@ -114,6 +121,17 @@ describe('CatchesService 이미지 해시', () => {
);
});
+ it('대회 코드가 맞지 않으면 파일을 지우고 기록을 만들지 않는다', async () => {
+ prisma.catch.findFirst.mockResolvedValue(null);
+ tournaments.resolveDayCodeClaim.mockRejectedValueOnce(new BadRequestException('대회 코드가 맞지 않습니다.'));
+
+ await expect(
+ service.createCertified('user-1', file, { tournamentId: 'tour-1', dayCode: 'NOPE' }),
+ ).rejects.toBeInstanceOf(BadRequestException);
+ expect(removeUploadedFile).toHaveBeenCalledWith(file.path);
+ expect(prisma.catch.create).not.toHaveBeenCalled();
+ });
+
it('자랑 기록을 지우면 이미지 해시를 비운다', async () => {
prisma.catch.findUnique.mockResolvedValue({
id: 'catch-1',
diff --git a/fishing/apps/api/src/catches/catches.service.ts b/fishing/apps/api/src/catches/catches.service.ts
index 88c1972..1138828 100644
--- a/fishing/apps/api/src/catches/catches.service.ts
+++ b/fishing/apps/api/src/catches/catches.service.ts
@@ -13,6 +13,7 @@ import {
import { isUniqueConstraintError, softDeleteCatchData } from '../common/upload/catch-retention.util';
import { computePerceptualHash, isNearDuplicate } from '../common/upload/image-phash.util';
import { parsePageLimit } from '../common/pagination.util';
+import { TournamentsService } from '../tournaments/tournaments.service';
function claimedLocation(body: { locationLat?: unknown; locationLng?: unknown }) {
const lat = Number(body.locationLat);
@@ -30,6 +31,7 @@ export class CatchesService {
constructor(
private prisma: PrismaService,
private aiService: AiService,
+ private tournaments: TournamentsService,
) {}
@@ -51,6 +53,14 @@ export class CatchesService {
const perceptualHash = await computePerceptualHash(file.path);
await this.assertUniquePerceptualHash(perceptualHash, file.path);
+ let claim;
+ try {
+ claim = await this.tournaments.resolveDayCodeClaim(userId, body);
+ } catch (error) {
+ await removeUploadedFile(file.path);
+ throw error;
+ }
+
const imageUrl = `/uploads/${file.filename}`;
const catch_ = await this.createCertifiedRow(
@@ -61,6 +71,7 @@ export class CatchesService {
perceptualHash,
body,
freshness,
+ claim,
);
try {
@@ -153,6 +164,7 @@ export class CatchesService {
perceptualHash: string | null,
body: { locationName?: string; memo?: string },
freshness: { takenAt?: Date; exifLat?: number; exifLng?: number; integrityNote?: string },
+ claim: { claimedTournamentId: string | null; claimedDayCode: string | null; dayCodeStatus: string | null },
) {
try {
return await this.prisma.catch.create({
@@ -161,6 +173,9 @@ export class CatchesService {
imageUrl,
imageHash,
perceptualHash,
+ claimedTournamentId: claim.claimedTournamentId,
+ claimedDayCode: claim.claimedDayCode,
+ dayCodeStatus: claim.dayCodeStatus,
locationName: body.locationName,
memo: body.memo,
photoTakenAt: freshness.takenAt,
diff --git a/fishing/apps/api/src/tournaments/day-code.service.spec.ts b/fishing/apps/api/src/tournaments/day-code.service.spec.ts
new file mode 100644
index 0000000..1429328
--- /dev/null
+++ b/fishing/apps/api/src/tournaments/day-code.service.spec.ts
@@ -0,0 +1,70 @@
+import { BadRequestException, ForbiddenException } from '@nestjs/common';
+import { TournamentsService } from './tournaments.service';
+
+describe('TournamentsService 일일 코드', () => {
+ const now = new Date('2026-10-20T03:00:00.000Z');
+ const prisma = {
+ user: { findUnique: jest.fn() },
+ tournament: { findUnique: jest.fn() },
+ tournamentEntry: { findUnique: jest.fn() },
+ tournamentDayCode: { findUnique: jest.fn(), upsert: jest.fn() },
+ };
+ const service = new TournamentsService(prisma as never);
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ prisma.tournament.findUnique.mockResolvedValue({ id: 'tour-1' });
+ });
+
+ it('관리자가 서울 날짜 코드를 저장한다', async () => {
+ prisma.user.findUnique.mockResolvedValue({ role: 'admin' });
+ prisma.tournamentDayCode.upsert.mockResolvedValue({ code: 'LAKE1' });
+
+ await service.setTodayDayCode('admin-1', 'tour-1', 'lake1', now);
+
+ expect(prisma.tournamentDayCode.upsert).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: { tournamentId_codeDate: { tournamentId: 'tour-1', codeDate: '2026-10-20' } },
+ create: { tournamentId: 'tour-1', codeDate: '2026-10-20', code: 'LAKE1' },
+ }),
+ );
+ });
+
+ it('관리자가 아니면 코드를 저장하지 않는다', async () => {
+ prisma.user.findUnique.mockResolvedValue({ role: 'user' });
+ await expect(service.setTodayDayCode('user-1', 'tour-1', 'LAKE1', now)).rejects.toBeInstanceOf(
+ ForbiddenException,
+ );
+ });
+
+ it('참가자의 맞는 코드는 검수 대기로 남긴다', async () => {
+ prisma.tournamentEntry.findUnique.mockResolvedValue({ paymentStatus: 'free' });
+ prisma.tournamentDayCode.findUnique.mockResolvedValue({ code: 'LAKE1' });
+
+ await expect(
+ service.resolveDayCodeClaim('user-1', { tournamentId: 'tour-1', dayCode: 'lake1' }, now),
+ ).resolves.toEqual({
+ claimedTournamentId: 'tour-1',
+ claimedDayCode: 'LAKE1',
+ dayCodeStatus: 'pending',
+ });
+ });
+
+ it('코드가 없으면 제출을 요구하지 않는다', async () => {
+ await expect(service.resolveDayCodeClaim('user-1', {}, now)).resolves.toEqual({
+ claimedTournamentId: null,
+ claimedDayCode: null,
+ dayCodeStatus: null,
+ });
+ expect(prisma.tournamentEntry.findUnique).not.toHaveBeenCalled();
+ });
+
+ it('다른 코드는 거절한다', async () => {
+ prisma.tournamentEntry.findUnique.mockResolvedValue({ paymentStatus: 'paid' });
+ prisma.tournamentDayCode.findUnique.mockResolvedValue({ code: 'LAKE1' });
+
+ await expect(
+ service.resolveDayCodeClaim('user-1', { tournamentId: 'tour-1', dayCode: 'RIVER' }, now),
+ ).rejects.toBeInstanceOf(BadRequestException);
+ });
+});
diff --git a/fishing/apps/api/src/tournaments/day-code.util.spec.ts b/fishing/apps/api/src/tournaments/day-code.util.spec.ts
new file mode 100644
index 0000000..c6bf6a4
--- /dev/null
+++ b/fishing/apps/api/src/tournaments/day-code.util.spec.ts
@@ -0,0 +1,15 @@
+import { normalizeDayCode, seoulDateKey } from './day-code.util';
+
+describe('대회 일일 코드', () => {
+ it('서울 날짜 기준으로 날짜 키를 만든다', () => {
+ expect(seoulDateKey(new Date('2026-10-20T14:59:00.000Z'))).toBe('2026-10-20');
+ expect(seoulDateKey(new Date('2026-10-20T15:00:00.000Z'))).toBe('2026-10-21');
+ });
+
+ it('영문과 숫자 4~12자만 코드로 받는다', () => {
+ expect(normalizeDayCode(' ab12 ')).toBe('AB12');
+ expect(normalizeDayCode('abc')).toBeNull();
+ expect(normalizeDayCode('한글코드')).toBeNull();
+ expect(normalizeDayCode(12)).toBeNull();
+ });
+});
diff --git a/fishing/apps/api/src/tournaments/day-code.util.ts b/fishing/apps/api/src/tournaments/day-code.util.ts
new file mode 100644
index 0000000..db308ca
--- /dev/null
+++ b/fishing/apps/api/src/tournaments/day-code.util.ts
@@ -0,0 +1,16 @@
+const CODE_PATTERN = /^[A-Z0-9]{4,12}$/;
+
+export function seoulDateKey(now = new Date()): string {
+ return new Intl.DateTimeFormat('en-CA', {
+ timeZone: 'Asia/Seoul',
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ }).format(now);
+}
+
+export function normalizeDayCode(value: unknown): string | null {
+ if (typeof value !== 'string') return null;
+ const code = value.trim().toUpperCase();
+ return CODE_PATTERN.test(code) ? code : null;
+}
diff --git a/fishing/apps/api/src/tournaments/dto/set-day-code.dto.ts b/fishing/apps/api/src/tournaments/dto/set-day-code.dto.ts
new file mode 100644
index 0000000..d723e79
--- /dev/null
+++ b/fishing/apps/api/src/tournaments/dto/set-day-code.dto.ts
@@ -0,0 +1,8 @@
+import { IsString, MaxLength, MinLength } from 'class-validator';
+
+export class SetDayCodeDto {
+ @IsString()
+ @MinLength(4)
+ @MaxLength(12)
+ code: string;
+}
diff --git a/fishing/apps/api/src/tournaments/tournaments.controller.ts b/fishing/apps/api/src/tournaments/tournaments.controller.ts
index 6b321a4..e10a835 100644
--- a/fishing/apps/api/src/tournaments/tournaments.controller.ts
+++ b/fishing/apps/api/src/tournaments/tournaments.controller.ts
@@ -7,6 +7,7 @@ import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import { TournamentsService } from './tournaments.service';
+import { SetDayCodeDto } from './dto/set-day-code.dto';
import { createImageUploadInterceptor, saveUploadedFile } from '../common/upload/upload.config';
import { ImageMagicInterceptor } from '../common/upload/image-magic.interceptor';
@@ -225,6 +226,22 @@ export class TournamentsController {
}
+ @Get(':id/day-code')
+ @ApiOperation({ summary: '오늘(서울) 대회 코드 조회' })
+ async getDayCode(@Param('id') id: string) {
+ const result = await this.tournamentsService.getTodayDayCode(id);
+ return { success: true, data: result };
+ }
+
+ @Post(':id/day-code')
+ @UseGuards(JwtAuthGuard)
+ @ApiBearerAuth()
+ @ApiOperation({ summary: '[어드민] 오늘(서울) 대회 코드 등록' })
+ async setDayCode(@Param('id') id: string, @CurrentUser() user: any, @Body() body: SetDayCodeDto) {
+ const result = await this.tournamentsService.setTodayDayCode(user.id, id, body.code);
+ return { success: true, data: result };
+ }
+
@Delete(':id')
@UseGuards(JwtAuthGuard)
diff --git a/fishing/apps/api/src/tournaments/tournaments.service.ts b/fishing/apps/api/src/tournaments/tournaments.service.ts
index 745a50f..c7459ca 100644
--- a/fishing/apps/api/src/tournaments/tournaments.service.ts
+++ b/fishing/apps/api/src/tournaments/tournaments.service.ts
@@ -1,5 +1,18 @@
import { Injectable, NotFoundException, BadRequestException, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
+import { normalizeDayCode, seoulDateKey } from './day-code.util';
+
+export type DayCodeClaim = {
+ claimedTournamentId: string | null;
+ claimedDayCode: string | null;
+ dayCodeStatus: string | null;
+};
+
+const EMPTY_DAY_CODE_CLAIM: DayCodeClaim = {
+ claimedTournamentId: null,
+ claimedDayCode: null,
+ dayCodeStatus: null,
+};
@Injectable()
export class TournamentsService {
@@ -119,4 +132,60 @@ export class TournamentsService {
return { deleted: true };
}
+
+ async getTodayDayCode(tournamentId: string, now = new Date()) {
+ const tournament = await this.prisma.tournament.findUnique({ where: { id: tournamentId } });
+ if (!tournament) throw new NotFoundException('대회를 찾을 수 없습니다.');
+ const codeDate = seoulDateKey(now);
+ const row = await this.prisma.tournamentDayCode.findUnique({
+ where: { tournamentId_codeDate: { tournamentId, codeDate } },
+ });
+ 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('관리자만 대회 코드를 등록할 수 있습니다.');
+
+ 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 } },
+ create: { tournamentId, codeDate, code },
+ update: { code },
+ });
+ }
+
+ async resolveDayCodeClaim(userId: string, body: { tournamentId?: unknown; dayCode?: unknown }, now = new Date()): Promise {
+ const tournamentId = typeof body.tournamentId === 'string' ? body.tournamentId.trim() : '';
+ const rawCode = typeof body.dayCode === 'string' ? body.dayCode : '';
+ if (!tournamentId && !rawCode.trim()) return EMPTY_DAY_CODE_CLAIM;
+ if (!tournamentId || !rawCode.trim()) {
+ throw new BadRequestException('대회와 오늘의 코드를 함께 보내야 합니다.');
+ }
+
+ const code = normalizeDayCode(rawCode);
+ if (!code) throw new BadRequestException('대회 코드는 4~12자의 영문 또는 숫자여야 합니다.');
+
+ const entry = await this.prisma.tournamentEntry.findUnique({
+ where: { tournamentId_userId: { tournamentId, userId } },
+ });
+ if (!entry || (entry.paymentStatus !== 'free' && entry.paymentStatus !== 'paid')) {
+ throw new BadRequestException('참가 중인 대회만 코드를 제출할 수 있습니다.');
+ }
+
+ const codeDate = seoulDateKey(now);
+ const stored = await this.prisma.tournamentDayCode.findUnique({
+ where: { tournamentId_codeDate: { tournamentId, codeDate } },
+ });
+ if (!stored) throw new BadRequestException('오늘 대회 코드가 아직 없습니다.');
+ if (stored.code !== code) throw new BadRequestException('대회 코드가 맞지 않습니다.');
+
+ return { claimedTournamentId: tournamentId, claimedDayCode: code, dayCodeStatus: 'pending' };
+ }
}
diff --git a/fishing/apps/web/src/app/admin/catches/page.tsx b/fishing/apps/web/src/app/admin/catches/page.tsx
index 9ad2a20..28bc361 100644
--- a/fishing/apps/web/src/app/admin/catches/page.tsx
+++ b/fishing/apps/web/src/app/admin/catches/page.tsx
@@ -37,6 +37,12 @@ function AdminCatchesContent() {
const [rejectId, setRejectId] = useState(null);
const queryClient = useQueryClient();
+ const dayCodeMutation = useMutation({
+ mutationFn: ({ id, status: dayCodeStatus }: { id: string; status: 'accepted' | 'rejected' }) =>
+ api.patch(`/admin/catches/${id}/day-code`, { status: dayCodeStatus }),
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ['admin-catches'] }),
+ });
+
useEffect(() => {
const s = searchParams.get('status');
if (s) setStatus(s);
@@ -100,6 +106,17 @@ function AdminCatchesContent() {
{c.lengthCm ? ` · ${formatLength(c.lengthCm)}` : ''}
{c.locationName ? `📍 ${c.locationName}` : formatTimeAgo(c.createdAt)}
+ {c.claimedDayCode && (
+
+ 코드 {c.claimedDayCode} · {c.dayCodeStatus}
+
+
+
+ )}
{c.certification?.errorMessage && (
{c.certification.errorMessage}
)}
diff --git a/fishing/apps/web/src/app/admin/tournaments/page.tsx b/fishing/apps/web/src/app/admin/tournaments/page.tsx
index 01bea3d..3733494 100644
--- a/fishing/apps/web/src/app/admin/tournaments/page.tsx
+++ b/fishing/apps/web/src/app/admin/tournaments/page.tsx
@@ -20,6 +20,7 @@ export default function AdminTournamentsPage() {
const [editId, setEditId] = useState(null);
const [bannerFile, setBannerFile] = useState(null);
const [bannerPreview, setBannerPreview] = useState(null);
+ const [dayCodes, setDayCodes] = useState>({});
const { data: tournaments } = useQuery({
queryKey: ['admin-tournaments'],
@@ -42,6 +43,10 @@ export default function AdminTournamentsPage() {
},
});
+ const dayCodeMutation = useMutation({
+ mutationFn: ({ id, code }: { id: string; code: string }) => api.post(`/tournaments/${id}/day-code`, { code }),
+ });
+
const statusMutation = useMutation({
mutationFn: ({ id, status }: { id: string; status: string }) => api.patch(`/tournaments/${id}/status`, { status }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['admin-tournaments'] }),
@@ -130,6 +135,20 @@ export default function AdminTournamentsPage() {
>
{STATUS_OPTIONS.map((s) => )}
+ setDayCodes((prev) => ({ ...prev, [t.id]: e.target.value }))}
+ />
+