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,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;
27 changes: 23 additions & 4 deletions fishing/apps/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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[]

Expand Down Expand Up @@ -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")
}
Expand All @@ -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
Expand Down
35 changes: 35 additions & 0 deletions fishing/apps/api/src/admin/admin-day-code.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
8 changes: 8 additions & 0 deletions fishing/apps/api/src/admin/admin.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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') {
Expand Down
14 changes: 14 additions & 0 deletions fishing/apps/api/src/admin/admin.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions fishing/apps/api/src/admin/dto/review-day-code.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { IsIn } from 'class-validator';

export class ReviewDayCodeDto {
@IsIn(['accepted', 'rejected'])
status: 'accepted' | 'rejected';
}
3 changes: 2 additions & 1 deletion fishing/apps/api/src/catches/catches.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
})
Expand Down
22 changes: 20 additions & 2 deletions fishing/apps/api/src/catches/catches.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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',
Expand Down
15 changes: 15 additions & 0 deletions fishing/apps/api/src/catches/catches.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -30,6 +31,7 @@ export class CatchesService {
constructor(
private prisma: PrismaService,
private aiService: AiService,
private tournaments: TournamentsService,
) {}


Expand All @@ -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(
Expand All @@ -61,6 +71,7 @@ export class CatchesService {
perceptualHash,
body,
freshness,
claim,
);

try {
Expand Down Expand Up @@ -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({
Expand All @@ -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,
Expand Down
70 changes: 70 additions & 0 deletions fishing/apps/api/src/tournaments/day-code.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
15 changes: 15 additions & 0 deletions fishing/apps/api/src/tournaments/day-code.util.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
16 changes: 16 additions & 0 deletions fishing/apps/api/src/tournaments/day-code.util.ts
Original file line number Diff line number Diff line change
@@ -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;
}
8 changes: 8 additions & 0 deletions fishing/apps/api/src/tournaments/dto/set-day-code.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { IsString, MaxLength, MinLength } from 'class-validator';

export class SetDayCodeDto {
@IsString()
@MinLength(4)
@MaxLength(12)
code: string;
}
Loading
Loading