From cded9ca4b5c2ad248c804c6258b565e7c012e91d Mon Sep 17 00:00:00 2001 From: yhaeul Date: Mon, 22 Jun 2026 00:02:03 +0900 Subject: [PATCH 1/2] feat: add internal API to update user role (admin/user) --- pages/api/v2/admin/users/[userId]/role.ts | 64 +++++++++++++++++++++++ server/ENV.ts | 3 ++ server/service/user.service.ts | 8 +++ src/lib/openapi/register-paths.ts | 55 +++++++++++++++++++ src/lib/schemas/admin.ts | 30 +++++++++++ 5 files changed, 160 insertions(+) create mode 100644 pages/api/v2/admin/users/[userId]/role.ts diff --git a/pages/api/v2/admin/users/[userId]/role.ts b/pages/api/v2/admin/users/[userId]/role.ts new file mode 100644 index 0000000..f3dca3d --- /dev/null +++ b/pages/api/v2/admin/users/[userId]/role.ts @@ -0,0 +1,64 @@ +import { NextApiRequest, NextApiResponse } from 'next' +import { timingSafeEqual } from 'crypto' +import { ZodIssue, z } from 'zod' +import { ENV } from 'server/ENV' +import { Provider } from 'server/provider' +import { UserService } from 'server/service/user.service' +import { UserNotFoundError } from 'server/domain/error' +import { + AdminUserRoleUpdateParamsSchema, + AdminUserRoleUpdateSchema, + type AdminUserRoleUpdateResponse, +} from 'src/lib/schemas/admin' + +const isValidAdminRoleApiKey = (apiKey: string | string[] | undefined): boolean => { + const expectedApiKey = ENV.ADMIN_ROLE.API_KEY + if (!expectedApiKey || !apiKey || Array.isArray(apiKey)) { + return false + } + + const expectedBuffer = new TextEncoder().encode(expectedApiKey) + const actualBuffer = new TextEncoder().encode(apiKey) + return ( + expectedBuffer.length === actualBuffer.length && timingSafeEqual(expectedBuffer, actualBuffer) + ) +} + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + try { + if (!isValidAdminRoleApiKey(req.headers['x-internal-api-key'])) { + return res.status(401).send('Unauthorized') + } + + if (req.method === 'PATCH') { + const { userId } = AdminUserRoleUpdateParamsSchema.parse(req.query) + const body = AdminUserRoleUpdateSchema.parse(req.body) + + const userService = Provider.getService(UserService) + await userService.updateUserRole(userId, body.role) + + return res.status(200).json({ + success: true, + message: '사용자 권한이 변경되었습니다.', + data: { + user_id: userId, + role: body.role, + }, + }) + } + } catch (err) { + if (err instanceof UserNotFoundError) { + return res.status(404).send('User not found') + } + if (err instanceof z.ZodError) { + return res.status(400).json(err.errors) + } + console.error('updateUserRole error: ', err) + return res.status(500).send('Internal Server Error') + } + + return res.status(405).end() +} diff --git a/server/ENV.ts b/server/ENV.ts index 7286670..ce06a70 100644 --- a/server/ENV.ts +++ b/server/ENV.ts @@ -26,6 +26,9 @@ export const ENV = { APP_VERSION_POLICY: { API_KEY: process.env.APP_VERSION_POLICY_API_KEY ?? '', }, + ADMIN_ROLE: { + API_KEY: process.env.ADMIN_ROLE_API_KEY ?? '', + }, R2: { ACCOUNT_ID: process.env.R2_ACCOUNT_ID ?? '', ACCESS_KEY_ID: process.env.R2_ACCESS_KEY_ID ?? '', diff --git a/server/service/user.service.ts b/server/service/user.service.ts index 879e075..ef915d6 100644 --- a/server/service/user.service.ts +++ b/server/service/user.service.ts @@ -82,6 +82,14 @@ export class UserService { } } + public async updateUserRole(userId: string, role: UserRole): Promise { + const user = await this.userRepository.findOneBy({ id: userId }) + if (!user) { + throw new UserNotFoundError(`User not found`) + } + await this.userRepository.update(userId, { role }) + } + public async assertAdminRole(accountId: string): Promise { const accountUser = await this.accountUserRepository.findOne({ where: { accountId }, diff --git a/src/lib/openapi/register-paths.ts b/src/lib/openapi/register-paths.ts index e78cc88..f142f9c 100644 --- a/src/lib/openapi/register-paths.ts +++ b/src/lib/openapi/register-paths.ts @@ -72,6 +72,9 @@ import { AdminClubsResponseSchema, AdminClubStatusUpdateResponseSchema, AdminClubStatusUpdateSchema, + AdminUserRoleUpdateParamsSchema, + AdminUserRoleUpdateResponseSchema, + AdminUserRoleUpdateSchema, } from 'src/lib/schemas/admin' import { CollegeMajorsQuerySchema, @@ -2213,6 +2216,58 @@ registry.registerPath({ }, }) +registry.registerPath({ + method: 'patch', + path: '/api/v2/admin/users/{userId}/role', + tags: ['Admin'], + summary: '사용자 권한 변경', + description: + '내부 API 키 인증을 통해 특정 사용자의 권한을 admin 또는 user로 변경합니다. x-internal-api-key 헤더에 발급된 키를 포함해야 합니다.', + security: [{ internalApiKeyAuth: [] }], + request: { + params: AdminUserRoleUpdateParamsSchema, + body: { + content: { + 'application/json': { + schema: AdminUserRoleUpdateSchema, + examples: { + grant_admin: { + summary: 'admin 권한 부여', + value: { role: 'admin' }, + }, + revoke_admin: { + summary: 'user로 권한 변경', + value: { role: 'user' }, + }, + }, + }, + }, + }, + }, + responses: { + 200: { + description: '권한 변경 성공', + content: { + 'application/json': { + schema: AdminUserRoleUpdateResponseSchema, + example: { + success: true, + message: '사용자 권한이 변경되었습니다.', + data: { + user_id: '123e4567-e89b-12d3-a456-426614174000', + role: 'admin', + }, + }, + }, + }, + }, + 400: validationErrorResponse, + 401: unauthorizedResponse, + 404: notFoundResponse, + 500: internalServerErrorResponse, + }, +}) + export const registeredClubRankingSchema = ClubRankingSchema export const registeredMyReviewSchema = MyReviewSchema export const registeredUserSchema = UserSchema diff --git a/src/lib/schemas/admin.ts b/src/lib/schemas/admin.ts index ce12c3a..2a8ca23 100644 --- a/src/lib/schemas/admin.ts +++ b/src/lib/schemas/admin.ts @@ -1,5 +1,6 @@ import { z } from 'src/lib/schemas/zod' import { CLUB_STATUSES, REJECTED_CLUB_STATUS } from 'src/common/constants/club-status' +import { UserRole } from 'server/infra/database/entities/user-role.enum' const AdminClubManagerSchema = z.object({ name: z.string(), @@ -328,3 +329,32 @@ export const AdminClubVerificationRequestStatusUpdateResponseSchema = z export type AdminClubVerificationRequestStatusUpdateResponse = z.infer< typeof AdminClubVerificationRequestStatusUpdateResponseSchema > + +export const AdminUserRoleUpdateParamsSchema = z + .object({ + userId: z.string().uuid(), + }) + .openapi('AdminUserRoleUpdateParams') + +export type AdminUserRoleUpdateParams = z.infer + +export const AdminUserRoleUpdateSchema = z + .object({ + role: z.enum([UserRole.ADMIN, UserRole.USER]), + }) + .openapi('AdminUserRoleUpdate') + +export type AdminUserRoleUpdate = z.infer + +export const AdminUserRoleUpdateResponseSchema = z + .object({ + success: z.literal(true), + message: z.string(), + data: z.object({ + user_id: z.string().uuid(), + role: z.string(), + }), + }) + .openapi('AdminUserRoleUpdateResponse') + +export type AdminUserRoleUpdateResponse = z.infer From 62a23f92cc2725ff60660c8c1c57aa4cde8f25c5 Mon Sep 17 00:00:00 2001 From: yhaeul Date: Tue, 23 Jun 2026 03:19:58 +0900 Subject: [PATCH 2/2] refactor: reuse APP_VERSION_POLICY_API_KEY for admin role update API --- pages/api/v2/admin/users/[userId]/role.ts | 2 +- server/ENV.ts | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/pages/api/v2/admin/users/[userId]/role.ts b/pages/api/v2/admin/users/[userId]/role.ts index f3dca3d..2979191 100644 --- a/pages/api/v2/admin/users/[userId]/role.ts +++ b/pages/api/v2/admin/users/[userId]/role.ts @@ -12,7 +12,7 @@ import { } from 'src/lib/schemas/admin' const isValidAdminRoleApiKey = (apiKey: string | string[] | undefined): boolean => { - const expectedApiKey = ENV.ADMIN_ROLE.API_KEY + const expectedApiKey = ENV.APP_VERSION_POLICY.API_KEY if (!expectedApiKey || !apiKey || Array.isArray(apiKey)) { return false } diff --git a/server/ENV.ts b/server/ENV.ts index ce06a70..157d71f 100644 --- a/server/ENV.ts +++ b/server/ENV.ts @@ -26,9 +26,7 @@ export const ENV = { APP_VERSION_POLICY: { API_KEY: process.env.APP_VERSION_POLICY_API_KEY ?? '', }, - ADMIN_ROLE: { - API_KEY: process.env.ADMIN_ROLE_API_KEY ?? '', - }, + R2: { ACCOUNT_ID: process.env.R2_ACCOUNT_ID ?? '', ACCESS_KEY_ID: process.env.R2_ACCESS_KEY_ID ?? '',