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
13 changes: 13 additions & 0 deletions apps/client/src/app/webhooks/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Suspense } from 'react';

import { WebhooksPage } from '@/views/webhooks';

const Webhooks = () => {
return (
<Suspense fallback={<div>Loading...</div>}>
<WebhooksPage />
</Suspense>
);
};

export default Webhooks;
3 changes: 3 additions & 0 deletions apps/client/src/entities/webhooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './model/types';
export * from './model/schema';
export * from './model/constants';
23 changes: 23 additions & 0 deletions apps/client/src/entities/webhooks/model/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
export interface WebhookEventOption {
value: string;
label: string;
description: string;
}

export const WEBHOOK_EVENTS: WebhookEventOption[] = [
{ value: 'STUDENT_GRADUATED', label: 'student.graduated', description: '학생 졸업 처리' },
{ value: 'STUDENT_WITHDRAWN', label: 'student.withdrawn', description: '학생 자퇴 처리' },
{
value: 'STUDENT_STATUS_CHANGED',
label: 'student.status_changed',
description: '학생 상태 변경',
},
{ value: 'CLUB_CREATED', label: 'club.created', description: '동아리 생성' },
{ value: 'CLUB_UPDATED', label: 'club.updated', description: '동아리 정보 수정' },
{ value: 'CLUB_DELETED', label: 'club.deleted', description: '동아리 삭제' },
{ value: 'PROJECT_CREATED', label: 'project.created', description: '프로젝트 생성' },
{ value: 'PROJECT_UPDATED', label: 'project.updated', description: '프로젝트 수정' },
{ value: 'PROJECT_DELETED', label: 'project.deleted', description: '프로젝트 삭제' },
];

export const MAX_WEBHOOKS_PER_ACCOUNT = 10;
10 changes: 10 additions & 0 deletions apps/client/src/entities/webhooks/model/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { z } from 'zod';

export const WebhookFormSchema = z.object({
targetUrl: z
.url({ message: '올바른 URL 형식이 아닙니다.' })
.regex(/^https?:\/\//, { message: 'URL은 http:// 또는 https://로 시작해야 합니다.' }),
events: z.array(z.string()).min(1, { message: '구독할 이벤트를 최소 1개 이상 선택해주세요.' }),
});

export type WebhookFormType = z.infer<typeof WebhookFormSchema>;
33 changes: 33 additions & 0 deletions apps/client/src/entities/webhooks/model/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { ApiResponse } from '@repo/shared/types';

export interface Webhook {
id: number;
target_url: string;
events: string[];
is_active: boolean;
created_at: string;
}

export interface WebhookListData {
webhooks: Webhook[];
}

export interface CreateWebhookRequest {
target_url: string;
events: string[];
}

export interface UpdateWebhookRequest {
target_url?: string;
events?: string[];
}

export interface CreateWebhookData extends Webhook {
secret: string;
}

export type CreateWebhookResponse = ApiResponse<CreateWebhookData>;
export type UpdateWebhookResponse = ApiResponse<Webhook>;
export type WebhookListResponse = ApiResponse<WebhookListData>;

export type { WebhookFormType } from './schema';
2 changes: 2 additions & 0 deletions apps/client/src/views/webhooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default as WebhooksPage } from './ui/WebhooksPage';
export * from './model/useGetWebhooks';
19 changes: 19 additions & 0 deletions apps/client/src/views/webhooks/model/useGetWebhooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { get, webhookQueryKeys, webhookUrl } from '@repo/shared/api';
import { minutesToMs } from '@repo/shared/utils';
import { UseQueryOptions, useQuery } from '@tanstack/react-query';

import { WebhookListResponse } from '@/entities/webhooks';

export const useGetWebhooks = (
options?: Omit<UseQueryOptions<WebhookListResponse>, 'queryKey' | 'queryFn'>,
) =>
useQuery({
queryKey: webhookQueryKeys.getWebhooks(),
queryFn: () => get<WebhookListResponse>(webhookUrl.getWebhooks()),
staleTime: minutesToMs(5),
gcTime: minutesToMs(10),
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
...options,
});
75 changes: 75 additions & 0 deletions apps/client/src/views/webhooks/ui/WebhooksPage/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
'use client';

import { useState } from 'react';

import { PageHeader } from '@repo/shared/ui';
import { cn } from '@repo/shared/utils';

import { CreateWebhookData, MAX_WEBHOOKS_PER_ACCOUNT, Webhook } from '@/entities/webhooks';
import { useGetWebhooks } from '@/views/webhooks';
import { WebhookFormDialog, WebhookList, WebhookSuccessDialog } from '@/widgets/webhooks';

const WebhooksPage = () => {
const { data: webhooksData, isLoading } = useGetWebhooks();

const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [editingWebhook, setEditingWebhook] = useState<Webhook | null>(null);
const [isSuccessDialogOpen, setIsSuccessDialogOpen] = useState(false);
const [createdWebhook, setCreatedWebhook] = useState<CreateWebhookData | null>(null);

const handleEdit = (webhook: Webhook) => {
setEditingWebhook(webhook);
setIsEditDialogOpen(true);
};

const handleCreateSuccess = (data: CreateWebhookData) => {
setCreatedWebhook(data);
setIsSuccessDialogOpen(true);
};

const webhooks = webhooksData?.data.webhooks || [];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

API 응답 에러나 예기치 않은 응답 구조로 인해 webhooksData는 존재하지만 data 필드가 없을 수 있습니다. 런타임 에러를 방지하기 위해 옵셔널 체이닝을 한 단계 더 적용하여 webhooksData?.data?.webhooks 형태로 안전하게 접근하는 것을 권장합니다.

Suggested change
const webhooks = webhooksData?.data.webhooks || [];
const webhooks = webhooksData?.data?.webhooks || [];

const isAtLimit = webhooks.length >= MAX_WEBHOOKS_PER_ACCOUNT;

return (
<div className={cn('bg-background min-h-[calc(100vh-3.5rem)]')}>
<main className={cn('container mx-auto px-4 py-8')}>
<PageHeader
breadcrumb="DATAGSM / Webhook"
title="웹훅"
action={
<WebhookFormDialog
mode="create"
onCreateSuccess={handleCreateSuccess}
disabled={isAtLimit}
/>
}
/>

{isAtLimit && (
<p className={cn('text-muted-foreground mb-3 font-mono text-xs')}>
{'>'} 계정당 최대 {MAX_WEBHOOKS_PER_ACCOUNT}개의 웹훅을 등록할 수 있습니다.
</p>
)}

<div className={cn('border-foreground pixel-shadow border-2')}>
<WebhookList webhooks={webhooks} isLoading={isLoading} onEdit={handleEdit} />
</div>
</main>

<WebhookSuccessDialog
open={isSuccessDialogOpen}
onOpenChange={setIsSuccessDialogOpen}
webhook={createdWebhook}
/>

<WebhookFormDialog
mode="edit"
webhook={editingWebhook ?? undefined}
open={isEditDialogOpen}
onOpenChange={setIsEditDialogOpen}
/>
</div>
);
};

export default WebhooksPage;
7 changes: 7 additions & 0 deletions apps/client/src/widgets/webhooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export { default as WebhookFormDialog } from './ui/WebhookFormDialog';
export { default as WebhookList } from './ui/WebhookList';
export { default as WebhookSuccessDialog } from './ui/WebhookSuccessDialog';
export * from './model/useCreateWebhook';
export * from './model/useUpdateWebhook';
export * from './model/useDeleteWebhook';
export * from './model/useWebhookEventSelection';
18 changes: 18 additions & 0 deletions apps/client/src/widgets/webhooks/model/useCreateWebhook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { post, webhookQueryKeys, webhookUrl } from '@repo/shared/api';
import { UseMutationOptions, useMutation } from '@tanstack/react-query';
import { AxiosError } from 'axios';

import { CreateWebhookRequest, CreateWebhookResponse } from '@/entities/webhooks';

export const useCreateWebhook = (
options?: Omit<
UseMutationOptions<CreateWebhookResponse, AxiosError, CreateWebhookRequest>,
'mutationKey' | 'mutationFn'
>,
) =>
useMutation({
mutationKey: webhookQueryKeys.postWebhook(),
mutationFn: (data: CreateWebhookRequest) =>
post<CreateWebhookResponse>(webhookUrl.postWebhook(), data),
...options,
});
16 changes: 16 additions & 0 deletions apps/client/src/widgets/webhooks/model/useDeleteWebhook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { del, webhookQueryKeys, webhookUrl } from '@repo/shared/api';
import { BaseApiResponse } from '@repo/shared/types';
import { UseMutationOptions, useMutation } from '@tanstack/react-query';
import { AxiosError } from 'axios';

export const useDeleteWebhook = (
options?: Omit<
UseMutationOptions<BaseApiResponse, AxiosError, number>,
'mutationKey' | 'mutationFn'
>,
) =>
useMutation({
mutationKey: webhookQueryKeys.deleteWebhook(),
mutationFn: (webhookId) => del<BaseApiResponse>(webhookUrl.deleteWebhook(webhookId)),
...options,
});
23 changes: 23 additions & 0 deletions apps/client/src/widgets/webhooks/model/useUpdateWebhook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { patch, webhookQueryKeys, webhookUrl } from '@repo/shared/api';
import { UseMutationOptions, useMutation } from '@tanstack/react-query';
import { AxiosError } from 'axios';

import { UpdateWebhookRequest, UpdateWebhookResponse } from '@/entities/webhooks';

interface UpdateWebhookVariables {
webhookId: number;
data: UpdateWebhookRequest;
}

export const useUpdateWebhook = (
options?: Omit<
UseMutationOptions<UpdateWebhookResponse, AxiosError, UpdateWebhookVariables>,
'mutationKey' | 'mutationFn'
>,
) =>
useMutation({
mutationKey: webhookQueryKeys.patchWebhook(),
mutationFn: ({ webhookId, data }: UpdateWebhookVariables) =>
patch<UpdateWebhookResponse>(webhookUrl.patchWebhook(webhookId), data),
...options,
});
37 changes: 37 additions & 0 deletions apps/client/src/widgets/webhooks/model/useWebhookEventSelection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
'use client';

import { UseFormSetValue, UseFormWatch } from 'react-hook-form';

import { WebhookFormType } from '@/entities/webhooks';

interface UseWebhookEventSelectionParams {
watch: UseFormWatch<WebhookFormType>;
setValue: UseFormSetValue<WebhookFormType>;
}

export const useWebhookEventSelection = ({ watch, setValue }: UseWebhookEventSelectionParams) => {
const fieldName = 'events';

const handleEventToggle = (event: string) => {
const currentEvents = watch(fieldName) || [];

setValue(
fieldName,
currentEvents.includes(event)
? currentEvents.filter((id) => id !== event)
: [...currentEvents, event],
{ shouldValidate: true, shouldDirty: true },
);
};

const isEventChecked = (event: string) => {
const currentEvents = watch(fieldName) || [];

return currentEvents.includes(event);
};

return {
handleEventToggle,
isEventChecked,
};
};
Comment on lines +12 to +37

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

useWebhookEventSelection 훅 내부에서 watch('events')handleEventToggleisEventChecked가 호출될 때마다 반복적으로 실행되고 있습니다. 특히 isEventChecked는 렌더링 중에 모든 이벤트 옵션(현재 9개)에 대해 각각 호출되므로, 매번 watch를 호출하는 것은 비효율적입니다.

watch('events')를 훅의 최상위 레벨에서 한 번만 호출하여 변수에 할당하고, 이를 재사용하도록 개선하면 성능과 가독성을 모두 높일 수 있습니다.

export const useWebhookEventSelection = ({ watch, setValue }: UseWebhookEventSelectionParams) => {
  const fieldName = 'events';
  const currentEvents = watch(fieldName) || [];

  const handleEventToggle = (event: string) => {
    setValue(
      fieldName,
      currentEvents.includes(event)
        ? currentEvents.filter((id) => id !== event)
        : [...currentEvents, event],
      { shouldValidate: true, shouldDirty: true },
    );
  };

  const isEventChecked = (event: string) => {
    return currentEvents.includes(event);
  };

  return {
    handleEventToggle,
    isEventChecked,
  };
};

Loading
Loading