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
Expand Up @@ -8,6 +8,7 @@ import {
import { Provider } from 'server/provider'
import { ClubVerificationService } from 'server/service/club-verification.service'
import { UserService } from 'server/service/user.service'
import { MAX_OFFICIAL_VERIFICATION_RETRY_COUNT } from 'src/common/constants/official-verification-status'
import { ClubUuidParamsSchema } from 'src/lib/schemas/clubs'
import type { CreateVerificationRequestResponse } from 'src/lib/schemas/managers'
import { type ZodIssue, z } from 'zod'
Expand Down Expand Up @@ -35,6 +36,12 @@ export default async function handler(
club_uuid: request.clubId,
status: 'PENDING',
created_at: request.createdAt,
attempt_no: request.attemptNo,
retry_count: request.attemptNo - 1,
remaining_retry_count: Math.max(
MAX_OFFICIAL_VERIFICATION_RETRY_COUNT - (request.attemptNo - 1),
0,
),
},
})
}
Expand Down
14 changes: 14 additions & 0 deletions apps/web/server/domain/model/Club.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,20 @@ export type ClubManager = {

export type ManagedClubDetail = Club & {
managers: ClubManager[]
officialVerification: ManagedOfficialVerification
}

export type ManagedOfficialVerificationStatus = 'VERIFIED' | 'PENDING' | 'REJECTED' | 'UNVERIFIED'

export type ManagedOfficialVerification = {
status: ManagedOfficialVerificationStatus
requestId: number | null
attemptNo: number | null
retryCount: number
retryLimit: number
remainingRetryCount: number
rejectReason: string | null
requestedAt: string | null
}

export type ManagedClubManagementStatus =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ export class ClubVerificationRequestEntity {
@Column({ type: 'uuid', name: 'club_id' })
clubId: string

@Column({ type: 'smallint', name: 'attempt_no' })
attemptNo: number

@Column({ type: 'varchar', default: 'PENDING', name: 'status' })
status: string

Expand Down
58 changes: 40 additions & 18 deletions apps/web/server/service/club-verification.service.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { IsNull, type Repository } from 'typeorm'
import { MAX_OFFICIAL_VERIFICATION_RETRY_COUNT } from 'src/common/constants/official-verification-status'
import type { Repository } from 'typeorm'
import { ConflictError, ForbiddenError, NotFoundError } from '../domain/error'
import { ClubEntity } from '../infra/database/entities'
import { ClubManagerEntity } from '../infra/database/entities/club-manager.entity'
import { ClubVerificationRequestEntity } from '../infra/database/entities/club-verification-request.entity'
import { InjectRepository, Service } from '../provider'

const MAX_OFFICIAL_VERIFICATION_ATTEMPT_COUNT = MAX_OFFICIAL_VERIFICATION_RETRY_COUNT + 1

@Service
export class ClubVerificationService {
@InjectRepository(ClubEntity)
Expand All @@ -20,29 +23,48 @@ export class ClubVerificationService {
clubUuid: string,
serviceUserId: string,
): Promise<ClubVerificationRequestEntity> {
const club = await this.clubRepository.findOneBy({ uuid: clubUuid, deletedAt: IsNull() })
if (!club) {
throw new NotFoundError('club not found')
}

const manager = await this.clubManagerRepository.findOneBy({ clubId: clubUuid, serviceUserId })
if (!manager) {
throw new ForbiddenError('not a manager of this club')
}

if (club.isOfficialVerified) {
throw new ConflictError('club is already officially verified')
}
return this.clubRepository.manager.transaction(async (transactionManager) => {
const club = await transactionManager
.getRepository(ClubEntity)
.createQueryBuilder('club')
.setLock('pessimistic_write')
.where('club.uuid = :clubUuid', { clubUuid })
.andWhere('club.deleted_at IS NULL')
.getOne()
if (!club) {
throw new NotFoundError('club not found')
}

const pendingRequest = await this.clubVerificationRequestRepository.findOneBy({
clubId: clubUuid,
status: 'PENDING',
})
if (pendingRequest) {
throw new ConflictError('pending verification request already exists')
}
if (club.isOfficialVerified) {
throw new ConflictError('club is already officially verified')
}

const verificationRequestRepository = transactionManager.getRepository(
ClubVerificationRequestEntity,
)
const pendingRequest = await verificationRequestRepository.findOneBy({
clubId: clubUuid,
status: 'PENDING',
})
if (pendingRequest) {
throw new ConflictError('pending verification request already exists')
}

const request = this.clubVerificationRequestRepository.create({ clubId: clubUuid })
return this.clubVerificationRequestRepository.save(request)
const attemptCount = await verificationRequestRepository.countBy({ clubId: clubUuid })
if (attemptCount >= MAX_OFFICIAL_VERIFICATION_ATTEMPT_COUNT) {
throw new ConflictError('official verification retry limit exceeded')
}

const request = verificationRequestRepository.create({
clubId: clubUuid,
attemptNo: attemptCount + 1,
})
return verificationRequestRepository.save(request)
})
}
}
29 changes: 28 additions & 1 deletion apps/web/server/service/club.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
PUBLIC_CLUB_STATUS,
REJECTED_CLUB_STATUS,
} from 'src/common/constants/club-status'
import { MAX_OFFICIAL_VERIFICATION_RETRY_COUNT } from 'src/common/constants/official-verification-status'
import type {
ClubData,
ClubManagerRequest,
Expand Down Expand Up @@ -143,10 +144,23 @@ export class ClubService {

async getManagedClubByUuid(clubUuid: string, serviceUserId: string): Promise<ManagedClubDetail> {
await this.clubAccessService.assertManagedClub(clubUuid, serviceUserId)
const [club, managers] = await Promise.all([
const [club, managers, latestVerificationRequest] = await Promise.all([
this.clubAccessService.getExistingClub(clubUuid),
this.clubManagerRepository.findBy({ clubId: clubUuid }),
this.clubVerificationRequestRepository.findOne({
where: { clubId: clubUuid },
order: { createdAt: 'DESC', id: 'DESC' },
}),
])
const verificationStatus = club.isOfficialVerified
? 'VERIFIED'
: latestVerificationRequest?.status === PENDING_CLUB_STATUS
? 'PENDING'
: latestVerificationRequest?.status === REJECTED_CLUB_STATUS
? 'REJECTED'
: 'UNVERIFIED'
const retryCount = Math.max((latestVerificationRequest?.attemptNo ?? 1) - 1, 0)

return {
...toClubDomain(club),
managers: managers.map((m) => ({
Expand All @@ -155,6 +169,19 @@ export class ClubService {
phone: m.phone,
studentId: m.studentId,
})),
officialVerification: {
status: verificationStatus,
requestId: latestVerificationRequest ? Number(latestVerificationRequest.id) : null,
attemptNo: latestVerificationRequest?.attemptNo ?? null,
retryCount,
retryLimit: MAX_OFFICIAL_VERIFICATION_RETRY_COUNT,
remainingRetryCount: Math.max(MAX_OFFICIAL_VERIFICATION_RETRY_COUNT - retryCount, 0),
rejectReason:
verificationStatus === 'REJECTED'
? (latestVerificationRequest?.rejectReason ?? null)
: null,
requestedAt: latestVerificationRequest?.createdAt ?? null,
},
}
}

Expand Down
23 changes: 23 additions & 0 deletions apps/web/sql/260810.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/* 공식 인증 요청별 시도 번호를 보존합니다. */

ALTER TABLE public.club_verification_request
ADD COLUMN attempt_no smallint;

WITH numbered_requests AS (
SELECT
id,
ROW_NUMBER() OVER (PARTITION BY club_id ORDER BY created_at ASC, id ASC) AS attempt_no
FROM public.club_verification_request
)
UPDATE public.club_verification_request AS request
SET attempt_no = numbered_requests.attempt_no
FROM numbered_requests
WHERE request.id = numbered_requests.id;

ALTER TABLE public.club_verification_request
ALTER COLUMN attempt_no SET NOT NULL,
ADD CONSTRAINT chk_club_verification_request_attempt_no_positive CHECK (attempt_no >= 1),
ADD CONSTRAINT uq_club_verification_request_attempt_no UNIQUE (club_id, attempt_no);

CREATE INDEX IF NOT EXISTS idx_club_verification_request_latest
ON public.club_verification_request (club_id, created_at DESC, id DESC);
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export const OFFICIAL_VERIFICATION_STATUSES = ['VERIFIED', 'PENDING', 'UNVERIFIED'] as const
export const MAX_OFFICIAL_VERIFICATION_RETRY_COUNT = 3

export type OfficialVerificationStatus = (typeof OFFICIAL_VERIFICATION_STATUSES)[number]
3 changes: 3 additions & 0 deletions apps/web/src/lib/openapi/register-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2077,6 +2077,9 @@ registry.registerPath({
club_uuid: '123e4567-e89b-12d3-a456-426614174000',
status: 'PENDING',
created_at: '2026-04-29T17:00:00Z',
attempt_no: 1,
retry_count: 0,
remaining_retry_count: 3,
},
},
},
Expand Down
10 changes: 10 additions & 0 deletions apps/web/src/lib/schemas/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,4 +164,14 @@ export const ClubManagerSchema = z

export const ManagedClubDetailSchema = ClubSchema.extend({
managers: z.array(ClubManagerSchema),
officialVerification: z.object({
status: z.enum(['VERIFIED', 'PENDING', 'REJECTED', 'UNVERIFIED']),
requestId: z.number().int().nullable(),
attemptNo: z.number().int().nullable(),
retryCount: z.number().int().nonnegative(),
retryLimit: z.number().int().positive(),
remainingRetryCount: z.number().int().nonnegative(),
rejectReason: z.string().nullable(),
requestedAt: z.string().nullable(),
}),
}).openapi('ManagedClubDetail')
3 changes: 3 additions & 0 deletions apps/web/src/lib/schemas/managers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,9 @@ export const CreateVerificationRequestResponseSchema = z
club_uuid: z.string().uuid(),
status: z.literal('PENDING'),
created_at: z.string(),
attempt_no: z.number().int(),
retry_count: z.number().int().nonnegative(),
remaining_retry_count: z.number().int().nonnegative(),
}),
})
.openapi('CreateVerificationRequestResponse')
Expand Down
Loading