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
6 changes: 6 additions & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
{
Expand Down
18 changes: 18 additions & 0 deletions src/app/workspaces/[workspaceId]/notifications/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <NotificationsView workspaceId={workspaceId} viewerId={viewerId} initialData={initialData} />;
}
31 changes: 31 additions & 0 deletions src/entities/notification/api/get-notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { createSupabaseServerClient } from '@/shared/api/supabase/server';
import type {
NotificationData,
NotificationItem,
NotificationPageData,
NotificationType,
} from '../model/notification.types';

Expand Down Expand Up @@ -69,3 +70,33 @@ export async function getNotifications(workspaceId: string): Promise<Notificatio
unreadCount: unreadCountResult.count ?? 0,
};
}

// 전체 알림 화면에서 현재 사용자에게 도착한 기록을 페이지 단위로 조회합니다.
export async function getNotificationsPage(input: {
workspaceId: string;
offset?: number;
}): Promise<NotificationPageData> {
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,
};
}
3 changes: 2 additions & 1 deletion src/entities/notification/index.ts
Original file line number Diff line number Diff line change
@@ -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';
6 changes: 6 additions & 0 deletions src/entities/notification/model/notification.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,10 @@ export interface NotificationData {
unreadCount: number;
}

// 전체 알림 화면에서 다음 목록을 이어서 조회하기 위한 페이지 단위 데이터입니다.
export interface NotificationPageData {
notifications: NotificationItem[];
hasMore: boolean;
}

export type NotificationActionResult<T> = { ok: true; data: T } | { ok: false; message: string };
18 changes: 9 additions & 9 deletions src/entities/resource/api/get-resource-library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<ResourceLibraryData> {
const parsedWorkspaceId = workspaceIdSchema.parse(workspaceId);
const supabase = await createSupabaseServerClient();
Expand All @@ -41,7 +42,7 @@ export async function getResourceLibrary(workspaceId: string): Promise<ResourceL
supabase
.from('resources')
.select(
'id, workspace_id, uploaded_by, title, description, resource_type, url, storage_path, created_at',
'id, workspace_id, uploaded_by, title, description, resource_type, link_provider, url, storage_path, created_at',
)
.eq('workspace_id', parsedWorkspaceId)
.order('created_at', { ascending: false }),
Expand Down Expand Up @@ -79,10 +80,9 @@ export async function getResourceLibrary(workspaceId: string): Promise<ResourceL
title: resource.title,
description: resource.description ?? '',
resourceType: resource.resource_type,
linkProvider: getLinkProvider(resource.url),
linkProvider: getLinkProvider(resource.url, resource.link_provider),
url: resource.url ?? undefined,
storagePath: resource.storage_path ?? undefined,
fileName: getFileName(resource.storage_path),
uploadedBy: resource.uploaded_by
? (profileNameById.get(resource.uploaded_by) ?? '알 수 없음')
: '탈퇴한 사용자',
Expand Down
69 changes: 27 additions & 42 deletions src/entities/resource/api/resource-actions.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
'use server';

// 링크·파일 자료를 워크스페이스 멤버 권한으로 저장하고 파일은 짧은 수명의 signed URL로 제공합니다.
import { randomUUID } from 'node:crypto';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
import {
RESOURCE_LINK_PROVIDERS,
type ResourceLinkProvider,
} from '../model/resource.types';
import { getCurrentUserId } from '@/shared/api/supabase/current-user';
import { createSupabaseServerClient } from '@/shared/api/supabase/server';

const RESOURCE_STORAGE_BUCKET = 'workspace-resources';
const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024;
const uuidSchema = z.guid();
const linkProviderSchema = z.enum(RESOURCE_LINK_PROVIDERS);
const resourceContentSchema = z.object({
title: z.string().trim().min(1, '자료 제목을 입력해주세요.').max(120),
description: z.string().trim().max(1_000),
Expand Down Expand Up @@ -48,17 +51,11 @@ function revalidateResourcePages(workspaceId: string): void {
revalidatePath(`/workspaces/${workspaceId}/dashboard`);
}

function sanitizeFileName(fileName: string): string {
const normalized = fileName
.normalize('NFC')
.replace(/[\\/\0-\x1f]/g, '_')
.trim();
return normalized || 'untitled';
}

function getDownloadFileName(storagePath: string, fallbackTitle: string): string {
const objectName = storagePath.split('/').at(-1);
return objectName?.replace(/^[0-9a-f-]{36}-/, '') || fallbackTitle;
const extension = storagePath.split('/').at(-1)?.match(/\.[a-z0-9]{1,10}$/i)?.[0] ?? '';
return fallbackTitle.toLowerCase().endsWith(extension.toLowerCase())
? fallbackTitle
: `${fallbackTitle}${extension}`;
}

async function getCurrentWorkspaceMember(workspaceId: string): Promise<{
Expand Down Expand Up @@ -117,10 +114,11 @@ export async function createLinkResource(input: {
title: string;
description: string;
url: string;
linkProvider: ResourceLinkProvider;
}): Promise<ResourceActionResult<{ id: string }>> {
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);
Expand All @@ -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,
})
Expand All @@ -150,36 +149,26 @@ export async function createLinkResource(input: {
}
}

export async function uploadFileResource(
formData: FormData,
): Promise<ResourceActionResult<{ id: string }>> {
// 브라우저에서 Storage 업로드를 마친 파일의 메타데이터만 DB에 저장합니다.
export async function createFileResource(input: {
workspaceId: string;
title: string;
description: string;
storagePath: string;
}): Promise<ResourceActionResult<{ id: string }>> {
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({
Expand All @@ -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,
})
Expand All @@ -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, '파일 정보 저장에 실패했습니다. 입력값을 확인해주세요.');
}
}

Expand Down
5 changes: 5 additions & 0 deletions src/entities/resource/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
17 changes: 16 additions & 1 deletion src/entities/resource/model/resource.types.ts
Original file line number Diff line number Diff line change
@@ -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<ResourceLinkProvider, string> = {
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;
Expand Down
Loading