diff --git a/next.config.ts b/next.config.ts index f6f93a2..a896d58 100644 --- a/next.config.ts +++ b/next.config.ts @@ -3,6 +3,12 @@ import type { NextConfig } from 'next'; const nextConfig: NextConfig = { /* config options here */ reactCompiler: true, + experimental: { + // 자료실의 실제 파일 제한(5MB)에 multipart 전송 오버헤드를 더해 Server Action 요청을 허용한다. + serverActions: { + bodySizeLimit: '6mb', + }, + }, images: { remotePatterns: [ { diff --git a/src/app/workspaces/[workspaceId]/notifications/page.tsx b/src/app/workspaces/[workspaceId]/notifications/page.tsx new file mode 100644 index 0000000..5693dd0 --- /dev/null +++ b/src/app/workspaces/[workspaceId]/notifications/page.tsx @@ -0,0 +1,18 @@ +// 워크스페이스 전체 알림 화면의 서버 라우트입니다. +import { getNotificationsPage } from '@/entities/notification'; +import { getCurrentUserId } from '@/shared/api/supabase/current-user'; +import { NotificationsView } from '@/views/notifications'; + +interface NotificationsPageProps { + params: Promise<{ workspaceId: string }>; +} + +export default async function NotificationsPage({ params }: NotificationsPageProps) { + const { workspaceId } = await params; + const [initialData, viewerId] = await Promise.all([ + getNotificationsPage({ workspaceId }), + getCurrentUserId(), + ]); + + return ; +} diff --git a/src/entities/notification/api/get-notifications.ts b/src/entities/notification/api/get-notifications.ts index 984fbbf..47494ec 100644 --- a/src/entities/notification/api/get-notifications.ts +++ b/src/entities/notification/api/get-notifications.ts @@ -7,6 +7,7 @@ import { createSupabaseServerClient } from '@/shared/api/supabase/server'; import type { NotificationData, NotificationItem, + NotificationPageData, NotificationType, } from '../model/notification.types'; @@ -69,3 +70,33 @@ export async function getNotifications(workspaceId: string): Promise { + const workspaceId = workspaceIdSchema.parse(input.workspaceId); + const offset = z.number().int().min(0).parse(input.offset ?? 0); + const pageSize = 20; + const supabase = await createSupabaseServerClient(); + const currentUserId = await getCurrentUserId(); + const { data, error } = await supabase + .from('notifications') + .select('id, workspace_id, type, title, body, link_path, read_at, created_at') + .eq('workspace_id', workspaceId) + .eq('recipient_id', currentUserId) + .order('created_at', { ascending: false }) + .range(offset, offset + pageSize); + + if (error) { + console.error('[notification] 전체 알림 조회 실패:', error); + throw new Error('알림을 불러오지 못했습니다.'); + } + + const rows = data ?? []; + return { + notifications: rows.slice(0, pageSize).map(toNotificationItem), + hasMore: rows.length > pageSize, + }; +} diff --git a/src/entities/notification/index.ts b/src/entities/notification/index.ts index 00a505f..f6c3ae8 100644 --- a/src/entities/notification/index.ts +++ b/src/entities/notification/index.ts @@ -1,10 +1,11 @@ // 알림 도메인이 헤더 기능에 제공하는 조회·읽음 API와 타입 공개 진입점입니다. -export { getNotifications } from './api/get-notifications'; +export { getNotifications, getNotificationsPage } from './api/get-notifications'; export { markNotificationsRead } from './api/notification-actions'; export { notificationsQueryKey } from './model/notification-query'; export type { NotificationActionResult, NotificationData, NotificationItem, + NotificationPageData, NotificationType, } from './model/notification.types'; diff --git a/src/entities/notification/model/notification.types.ts b/src/entities/notification/model/notification.types.ts index 3a37a31..aa87182 100644 --- a/src/entities/notification/model/notification.types.ts +++ b/src/entities/notification/model/notification.types.ts @@ -17,4 +17,10 @@ export interface NotificationData { unreadCount: number; } +// 전체 알림 화면에서 다음 목록을 이어서 조회하기 위한 페이지 단위 데이터입니다. +export interface NotificationPageData { + notifications: NotificationItem[]; + hasMore: boolean; +} + export type NotificationActionResult = { ok: true; data: T } | { ok: false; message: string }; diff --git a/src/entities/resource/api/get-resource-library.ts b/src/entities/resource/api/get-resource-library.ts index 525f122..e5b61a5 100644 --- a/src/entities/resource/api/get-resource-library.ts +++ b/src/entities/resource/api/get-resource-library.ts @@ -9,10 +9,16 @@ import type { ResourceLibraryData, ResourceLinkProvider, } from '../model/resource.types'; +import { isResourceLinkProvider } from '../model/resource.types'; const workspaceIdSchema = z.guid(); -function getLinkProvider(url: string | null): ResourceLinkProvider | undefined { +function getLinkProvider( + url: string | null, + storedProvider: string | null, +): ResourceLinkProvider | undefined { + if (storedProvider && isResourceLinkProvider(storedProvider)) return storedProvider; + if (!url) return undefined; try { @@ -27,11 +33,6 @@ function getLinkProvider(url: string | null): ResourceLinkProvider | undefined { return 'link'; } -function getFileName(storagePath: string | null): string | undefined { - const objectName = storagePath?.split('/').at(-1); - return objectName?.replace(/^[0-9a-f-]{36}-/, ''); -} - export async function getResourceLibrary(workspaceId: string): Promise { const parsedWorkspaceId = workspaceIdSchema.parse(workspaceId); const supabase = await createSupabaseServerClient(); @@ -41,7 +42,7 @@ export async function getResourceLibrary(workspaceId: string): Promise> { try { const value = z - .object({ workspaceId: uuidSchema, url: externalUrlSchema }) + .object({ workspaceId: uuidSchema, url: externalUrlSchema, linkProvider: linkProviderSchema }) .merge(resourceContentSchema) .parse(input); const { supabase, member } = await getCurrentWorkspaceMember(value.workspaceId); @@ -132,6 +130,7 @@ export async function createLinkResource(input: { title: value.title, description: value.description || null, resource_type: 'link', + link_provider: value.linkProvider, url: value.url, storage_path: null, }) @@ -150,36 +149,26 @@ export async function createLinkResource(input: { } } -export async function uploadFileResource( - formData: FormData, -): Promise> { +// 브라우저에서 Storage 업로드를 마친 파일의 메타데이터만 DB에 저장합니다. +export async function createFileResource(input: { + workspaceId: string; + title: string; + description: string; + storagePath: string; +}): Promise> { try { - const workspaceId = uuidSchema.parse(formData.get('workspaceId')); - const file = formData.get('file'); const value = resourceContentSchema.parse({ - title: formData.get('title'), - description: formData.get('description'), + title: input.title, + description: input.description, }); + const workspaceId = uuidSchema.parse(input.workspaceId); + const storagePath = z.string().trim().min(1).max(512).parse(input.storagePath); - if (!(file instanceof File) || file.size === 0) { - throwResourceActionError('업로드할 파일을 선택해주세요.'); - } - - if (file.size > MAX_FILE_SIZE_BYTES) { - throwResourceActionError('파일은 5MB 이하만 업로드할 수 있습니다.'); + if (!storagePath.startsWith(`${workspaceId}/`)) { + throwResourceActionError('올바르지 않은 파일 경로입니다.'); } const { supabase, member } = await getCurrentWorkspaceMember(workspaceId); - const storagePath = `${workspaceId}/${randomUUID()}-${sanitizeFileName(file.name)}`; - const { error: uploadError } = await supabase.storage - .from(RESOURCE_STORAGE_BUCKET) - .upload(storagePath, file, { contentType: file.type || undefined, upsert: false }); - - if (uploadError) { - console.error('[resource action] 파일 업로드 실패:', uploadError); - throwResourceActionError('파일 업로드에 실패했습니다. 잠시 후 다시 시도해주세요.'); - } - const { data, error: resourceError } = await supabase .from('resources') .insert({ @@ -188,6 +177,7 @@ export async function uploadFileResource( title: value.title, description: value.description || null, resource_type: 'file', + link_provider: null, url: null, storage_path: storagePath, }) @@ -196,18 +186,13 @@ export async function uploadFileResource( if (resourceError) { console.error('[resource action] 파일 메타데이터 저장 실패:', resourceError); - const { error: removeError } = await supabase.storage - .from(RESOURCE_STORAGE_BUCKET) - .remove([storagePath]); - - if (removeError) console.error('[resource action] 업로드 보상 삭제 실패:', removeError); throwResourceActionError('파일 정보 저장에 실패했습니다. 잠시 후 다시 시도해주세요.'); } revalidateResourcePages(workspaceId); return { ok: true, data: { id: data.id } }; } catch (error) { - return toActionFailure(error, '파일 저장에 실패했습니다. 입력값을 확인해주세요.'); + return toActionFailure(error, '파일 정보 저장에 실패했습니다. 입력값을 확인해주세요.'); } } diff --git a/src/entities/resource/index.ts b/src/entities/resource/index.ts index e8aa09a..792cd0e 100644 --- a/src/entities/resource/index.ts +++ b/src/entities/resource/index.ts @@ -7,6 +7,11 @@ export type { ResourceType, ResourceViewer, } from './model/resource.types'; +export { + isResourceLinkProvider, + RESOURCE_LINK_PROVIDER_LABEL, + RESOURCE_LINK_PROVIDERS, +} from './model/resource.types'; export { mockResources } from './model/mock-resources'; export { getResourceLibrary } from './api/get-resource-library'; export { resourceLibraryQueryKey } from './model/resource-query'; diff --git a/src/entities/resource/model/resource.types.ts b/src/entities/resource/model/resource.types.ts index 320ac97..321bf5b 100644 --- a/src/entities/resource/model/resource.types.ts +++ b/src/entities/resource/model/resource.types.ts @@ -1,7 +1,22 @@ // 자료실에서 파일과 외부 링크를 같은 목록으로 다루기 위한 타입입니다. export type ResourceType = 'file' | 'link'; -export type ResourceLinkProvider = 'link' | 'notion' | 'figma' | 'github'; +// 링크 자료에서 선택할 수 있는 제공자 값의 단일 기준입니다. +export const RESOURCE_LINK_PROVIDERS = ['notion', 'figma', 'link', 'github'] as const; + +export type ResourceLinkProvider = (typeof RESOURCE_LINK_PROVIDERS)[number]; + +// 링크 제공자 값을 화면 문구로 변환할 때 사용하는 단일 라벨 맵입니다. +export const RESOURCE_LINK_PROVIDER_LABEL: Record = { + notion: '노션', + figma: '피그마', + link: '기타', + github: '깃허브', +}; + +export function isResourceLinkProvider(value: string): value is ResourceLinkProvider { + return RESOURCE_LINK_PROVIDERS.includes(value as ResourceLinkProvider); +} export interface ResourceItem { id: string; diff --git a/src/features/manage-resources/model/use-resource-library-state.ts b/src/features/manage-resources/model/use-resource-library-state.ts index 3dd700e..537ed11 100644 --- a/src/features/manage-resources/model/use-resource-library-state.ts +++ b/src/features/manage-resources/model/use-resource-library-state.ts @@ -6,10 +6,10 @@ import { useMutation, useQuery } from '@tanstack/react-query'; import { toast } from 'sonner'; import { createLinkResource, + createFileResource, deleteResource, getResourceDownloadUrl, updateResource, - uploadFileResource, } from '@/entities/resource/api/resource-actions'; import { getResourceLibrary } from '@/entities/resource/api/get-resource-library'; import { resourceLibraryQueryKey } from '@/entities/resource/model/resource-query'; @@ -19,6 +19,9 @@ import type { ResourceLibraryData, ResourceType, } from '@/entities/resource'; +import { getSupabaseBrowserClient } from '@/shared/api/supabase/client'; + +const RESOURCE_STORAGE_BUCKET = 'workspace-resources'; interface UseResourceLibraryStateParams { initialData: ResourceLibraryData; @@ -45,8 +48,9 @@ export function useResourceLibraryState({ const [isDialogOpen, setIsDialogOpen] = useState(false); const [dialogResourceType, setDialogResourceType] = useState('file'); const [editingResourceId, setEditingResourceId] = useState(null); + const [isUploadingFile, setIsUploadingFile] = useState(false); const createLinkMutation = useMutation({ mutationFn: createLinkResource }); - const uploadFileMutation = useMutation({ mutationFn: uploadFileResource }); + const createFileMutation = useMutation({ mutationFn: createFileResource }); const downloadMutation = useMutation({ mutationFn: getResourceDownloadUrl }); const updateMutation = useMutation({ mutationFn: updateResource }); const deleteMutation = useMutation({ mutationFn: deleteResource }); @@ -73,14 +77,38 @@ export function useResourceLibraryState({ if (values.resourceType === 'file') { if (!values.file) return false; - const formData = new FormData(); - formData.set('workspaceId', workspaceId); - formData.set('file', values.file); - formData.set('title', title); - formData.set('description', description); - const result = await uploadFileMutation.mutateAsync(formData); + setIsUploadingFile(true); + // Storage 객체 키는 한글·공백 파일명 대신 UUID와 안전한 확장자만 사용한다. + const storagePath = `${workspaceId}/${crypto.randomUUID()}${getFileExtension( + values.file.name, + )}`; + const supabase = getSupabaseBrowserClient(); + const { error: uploadError } = await supabase.storage + .from(RESOURCE_STORAGE_BUCKET) + .upload(storagePath, values.file, { + contentType: values.file.type || undefined, + upsert: false, + }); + + if (uploadError) { + console.error('[resource] Storage 파일 업로드 실패:', uploadError); + toast.error('파일 업로드에 실패했습니다. 파일 용량과 저장소 권한을 확인해주세요.'); + return false; + } + + const result = await createFileMutation.mutateAsync({ + workspaceId, + title, + description, + storagePath, + }); if (!result.ok) { + const { error: removeError } = await supabase.storage + .from(RESOURCE_STORAGE_BUCKET) + .remove([storagePath]); + + if (removeError) console.error('[resource] 메타데이터 실패 후 파일 삭제 실패:', removeError); toast.error(result.message); return false; } @@ -90,6 +118,7 @@ export function useResourceLibraryState({ title, description, url: values.url.trim(), + linkProvider: values.linkProvider, }); if (!result.ok) { @@ -101,32 +130,27 @@ export function useResourceLibraryState({ await refetch(); setIsDialogOpen(false); return true; - } catch { + } catch (error) { + console.error('[resource] 자료 저장 실패:', error); toast.error('자료 저장에 실패했습니다. 잠시 후 다시 시도해주세요.'); return false; + } finally { + setIsUploadingFile(false); } }; const openFile = async (resource: ResourceItem): Promise => { - const downloadWindow = window.open('', '_blank'); - try { const result = await downloadMutation.mutateAsync({ workspaceId, resourceId: resource.id }); if (!result.ok) { - downloadWindow?.close(); toast.error(result.message); return; } - if (downloadWindow) { - downloadWindow.opener = null; - downloadWindow.location.href = result.data.url; - } else { - window.location.assign(result.data.url); - } + // Storage signed URL의 download 응답을 현재 창에서 요청해 새 탭을 만들지 않습니다. + window.location.assign(result.data.url); } catch { - downloadWindow?.close(); toast.error('파일 다운로드 링크를 만들지 못했습니다. 잠시 후 다시 시도해주세요.'); } }; @@ -184,7 +208,8 @@ export function useResourceLibraryState({ isLoading: isPending, isSaving: createLinkMutation.isPending || - uploadFileMutation.isPending || + isUploadingFile || + createFileMutation.isPending || updateMutation.isPending || deleteMutation.isPending, openDialog: (nextResourceType: ResourceType) => { @@ -201,3 +226,12 @@ export function useResourceLibraryState({ viewer: data.viewer, }; } + +// Storage 키에는 확장자만 보존해 파일 종류를 유지하고, 원본 파일명은 자료 제목으로 보관합니다. +function getFileExtension(fileName: string): string { + const extensionStart = fileName.lastIndexOf('.'); + if (extensionStart <= 0 || extensionStart === fileName.length - 1) return ''; + + const extension = fileName.slice(extensionStart + 1).toLowerCase(); + return extension && /^[a-z0-9]{1,10}$/.test(extension) ? `.${extension}` : ''; +} diff --git a/src/features/manage-resources/ui/ResourceAddDialog.tsx b/src/features/manage-resources/ui/ResourceAddDialog.tsx index 84d0673..ee469bd 100644 --- a/src/features/manage-resources/ui/ResourceAddDialog.tsx +++ b/src/features/manage-resources/ui/ResourceAddDialog.tsx @@ -1,9 +1,15 @@ 'use client'; // 자료실에 실제 파일 또는 외부 링크를 추가하는 모달입니다. -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState, type DragEvent } from 'react'; import { CloudUpload, GitBranch, Link, X } from 'lucide-react'; -import type { ResourceFormValues, ResourceLinkProvider, ResourceType } from '@/entities/resource'; +import { + RESOURCE_LINK_PROVIDER_LABEL, + RESOURCE_LINK_PROVIDERS, + type ResourceFormValues, + type ResourceLinkProvider, + type ResourceType, +} from '@/entities/resource'; import { cn } from '@/shared/lib/utils'; interface ResourceAddDialogProps { @@ -14,16 +20,7 @@ interface ResourceAddDialogProps { onSubmit: (values: ResourceFormValues) => Promise; } -const linkProviders: Array<{ - label: string; - value: ResourceLinkProvider; - icon: typeof Link; -}> = [ - { label: '링크', value: 'link', icon: Link }, - { label: '노션', value: 'notion', icon: Link }, - { label: '피그마', value: 'figma', icon: Link }, - { label: '깃허브', value: 'github', icon: GitBranch }, -]; +const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024; export function ResourceAddDialog({ isOpen, @@ -37,6 +34,8 @@ export function ResourceAddDialog({ const [linkProvider, setLinkProvider] = useState('link'); const [url, setUrl] = useState(''); const [file, setFile] = useState(null); + const [fileError, setFileError] = useState(null); + const [isDraggingFile, setIsDraggingFile] = useState(false); const [title, setTitle] = useState(''); const [description, setDescription] = useState(''); @@ -47,6 +46,8 @@ export function ResourceAddDialog({ setLinkProvider('link'); setUrl(''); setFile(null); + setFileError(null); + setIsDraggingFile(false); setTitle(''); setDescription(''); }, [initialResourceType]); @@ -69,6 +70,26 @@ export function ResourceAddDialog({ if (isSaved) resetForm(); }; + // 파일 선택과 드래그앤드롭 입력을 같은 검증 규칙으로 처리합니다. + const selectFile = (nextFile: File | null) => { + if (!nextFile) return; + + if (nextFile.size > MAX_FILE_SIZE_BYTES) { + setFile(null); + setFileError('파일은 5MB 이하만 업로드할 수 있습니다.'); + return; + } + + setFile(nextFile); + setFileError(null); + }; + + const handleFileDrop = (event: DragEvent) => { + event.preventDefault(); + setIsDraggingFile(false); + selectFile(event.dataTransfer.files.item(0)); + }; + const keepFocusInsideDialog = (event: React.KeyboardEvent) => { if (event.key !== 'Tab') { return; @@ -164,42 +185,62 @@ export function ResourceAddDialog({
{resourceType === 'file' ? ( -
diff --git a/src/shared/model/database.types.ts b/src/shared/model/database.types.ts index 09ef5a6..d99168c 100644 --- a/src/shared/model/database.types.ts +++ b/src/shared/model/database.types.ts @@ -351,6 +351,7 @@ export type Database = { created_at: string description: string | null id: string + link_provider: string | null resource_type: Database["public"]["Enums"]["resource_type"] storage_path: string | null title: string @@ -363,6 +364,7 @@ export type Database = { created_at?: string description?: string | null id?: string + link_provider?: string | null resource_type: Database["public"]["Enums"]["resource_type"] storage_path?: string | null title: string @@ -375,6 +377,7 @@ export type Database = { created_at?: string description?: string | null id?: string + link_provider?: string | null resource_type?: Database["public"]["Enums"]["resource_type"] storage_path?: string | null title?: string @@ -840,6 +843,10 @@ export type Database = { [_ in never]: never } Functions: { + create_task: { + Args: { p_due_date?: string; p_title: string; p_workspace_id: string } + Returns: string + } create_work_shift_type_and_ensure_weekly_entries: { Args: { p_week_start_date: string; p_workspace_id: string } Returns: { @@ -908,6 +915,10 @@ export type Database = { } Returns: undefined } + update_task_board: { + Args: { p_tasks: Json; p_workspace_id: string } + Returns: undefined + } } Enums: { calendar_event_type: "meeting" | "deadline" diff --git a/src/views/notifications/index.ts b/src/views/notifications/index.ts new file mode 100644 index 0000000..78fb225 --- /dev/null +++ b/src/views/notifications/index.ts @@ -0,0 +1,2 @@ +// 전체 알림 화면의 외부 공개 진입점입니다. +export { NotificationsView } from './ui/NotificationsView'; diff --git a/src/views/notifications/ui/NotificationsView.tsx b/src/views/notifications/ui/NotificationsView.tsx new file mode 100644 index 0000000..26b42ab --- /dev/null +++ b/src/views/notifications/ui/NotificationsView.tsx @@ -0,0 +1,142 @@ +'use client'; + +// 워크스페이스에서 수신한 전체 알림을 페이지 단위로 조회하고 읽음 처리하는 화면입니다. +import { useMemo } from 'react'; +import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { CheckCheck, ClipboardList, Megaphone } from 'lucide-react'; +import Link from 'next/link'; +import { toast } from 'sonner'; +import { + getNotificationsPage, + markNotificationsRead, + notificationsQueryKey, + type NotificationItem, + type NotificationPageData, +} from '@/entities/notification'; + +interface NotificationsViewProps { + workspaceId: string; + viewerId: string; + initialData: NotificationPageData; +} + +function getNotificationIcon(type: NotificationItem['type']) { + const className = 'h-5 w-5 text-indigo-500'; + return type === 'announcement_created' ? ( +