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 }))} + /> +