From 8a845a28fee746fc3b5462c2804a81ad406920ea Mon Sep 17 00:00:00 2001 From: 0011810 Date: Wed, 22 Jul 2026 18:03:16 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EC=9B=8C=ED=81=AC=EC=8A=A4?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EC=86=8C=EC=9C=A0=EA=B6=8C=20?= =?UTF-8?q?=EC=9D=B4=EC=A0=84=20=EB=B0=8F=20=EC=82=AD=EC=A0=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80=20(#87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - transfer_workspace_ownership RPC 추가 (security definer, 행 잠금으로 동시 탈퇴 레이스 차단) - deleteWorkspace 서버액션 추가 (owner 검증 -> Storage 정리 -> DB 삭제, 멤버 수 무관하게 owner면 항상 가능) - 설정 페이지를 워크스페이스 관리/팀 관리/프로필 설정 탭으로 재구성 - 소유권 이전/워크스페이스 삭제 후 react-query 캐시 무효화 처리 --- .../workspace/api/delete-workspace.ts | 118 +++++++++++++++ .../workspace/api/transfer-ownership.ts | 34 +++++ src/entities/workspace/index.ts | 7 +- .../ui/MemberProfileForm.tsx | 137 ++++++++++++------ src/features/manage-workspace-info/index.ts | 1 + .../ui/DeleteWorkspaceDialog.tsx | 108 ++++++++++++++ .../ui/DeleteWorkspaceSection.tsx | 42 ++++++ .../manage-workspace-members/index.ts | 1 + .../ui/OwnershipTransferSection.tsx | 42 ++++++ .../ui/TransferOwnershipDialog.tsx | 98 +++++++++++++ src/shared/model/database.types.ts | 4 + src/views/settings/model/settings-tab.ts | 6 +- src/views/settings/ui/SettingsView.tsx | 38 +++-- ...reate_workspace_ownership_transfer_rpc.sql | 63 ++++++++ 14 files changed, 637 insertions(+), 62 deletions(-) create mode 100644 src/entities/workspace/api/delete-workspace.ts create mode 100644 src/entities/workspace/api/transfer-ownership.ts create mode 100644 src/features/manage-workspace-info/ui/DeleteWorkspaceDialog.tsx create mode 100644 src/features/manage-workspace-info/ui/DeleteWorkspaceSection.tsx create mode 100644 src/features/manage-workspace-members/ui/OwnershipTransferSection.tsx create mode 100644 src/features/manage-workspace-members/ui/TransferOwnershipDialog.tsx create mode 100644 supabase/migrations/20260722000000_create_workspace_ownership_transfer_rpc.sql diff --git a/src/entities/workspace/api/delete-workspace.ts b/src/entities/workspace/api/delete-workspace.ts new file mode 100644 index 0000000..2b0d10b --- /dev/null +++ b/src/entities/workspace/api/delete-workspace.ts @@ -0,0 +1,118 @@ +'use server'; + +// 워크스페이스 삭제 서버액션 — owner면 멤버 수와 무관하게 실행 가능 (RLS: workspaces_delete_owner) +// Storage 파일은 FK 캐스케이드 대상이 아니라 workspaces row 삭제 전에 먼저 지워야 한다(이후엔 RLS가 막음). +import { revalidatePath } from 'next/cache'; +import { z } from 'zod'; +import { getCurrentUserId } from '@/shared/api/supabase/current-user'; +import { createSupabaseServerClient } from '@/shared/api/supabase/server'; + +const WORKSPACE_RESOURCES_BUCKET = 'workspace-resources'; +const STORAGE_LIST_PAGE_SIZE = 1000; + +const deleteWorkspaceInputSchema = z.object({ + workspaceId: z.guid(), +}); + +export type DeleteWorkspaceInput = z.infer; + +// .list()는 한 번에 최대 1000개만 반환하므로, offset을 늘려가며 전체 파일 목록을 모은다. +async function listAllStorageFileNames( + supabase: Awaited>, + workspaceId: string, +): Promise { + const names: string[] = []; + let offset = 0; + + while (true) { + const { data: page, error } = await supabase.storage + .from(WORKSPACE_RESOURCES_BUCKET) + .list(workspaceId, { + limit: STORAGE_LIST_PAGE_SIZE, + offset, + sortBy: { column: 'name', order: 'asc' }, + }); + + if (error) { + throw error; + } + if (!page || page.length === 0) { + break; + } + + names.push(...page.map((file) => file.name)); + + if (page.length < STORAGE_LIST_PAGE_SIZE) { + break; + } + offset += STORAGE_LIST_PAGE_SIZE; + } + + return names; +} + +export async function deleteWorkspace(input: DeleteWorkspaceInput): Promise { + const parsed = deleteWorkspaceInputSchema.safeParse(input); + if (!parsed.success) { + throw new Error('입력값이 올바르지 않습니다'); + } + + const { workspaceId } = parsed.data; + const userId = await getCurrentUserId(); + const supabase = await createSupabaseServerClient(); + + // Storage RLS는 "본인이 올린 파일"까지 허용해 일반 멤버가 직접 호출해도 자기 파일이 지워질 수 있어, + // 삭제 시도 전에 소유자인지부터 확인한다. + const { data: workspace, error: fetchError } = await supabase + .from('workspaces') + .select('owner_id') + .eq('id', workspaceId) + .maybeSingle(); + + if (fetchError || !workspace) { + throw new Error('워크스페이스를 찾을 수 없어요.'); + } + + if (workspace.owner_id !== userId) { + throw new Error('워크스페이스 소유자만 삭제할 수 있어요.'); + } + + let fileNames: string[]; + try { + fileNames = await listAllStorageFileNames(supabase, workspaceId); + } catch (listError) { + console.error('[deleteWorkspace] Storage 목록 조회 실패:', listError); + throw new Error('워크스페이스 삭제에 실패했어요. 잠시 후 다시 시도해주세요.'); + } + + // remove()도 한 번에 최대 1000개까지만 처리되므로, list()와 동일한 크기로 나눠서 삭제한다. + const paths = fileNames.map((name) => `${workspaceId}/${name}`); + for (let index = 0; index < paths.length; index += STORAGE_LIST_PAGE_SIZE) { + const batch = paths.slice(index, index + STORAGE_LIST_PAGE_SIZE); + const { error: removeError } = await supabase.storage + .from(WORKSPACE_RESOURCES_BUCKET) + .remove(batch); + + if (removeError) { + console.error('[deleteWorkspace] Storage 파일 삭제 실패:', removeError); + throw new Error('워크스페이스 삭제에 실패했어요. 잠시 후 다시 시도해주세요.'); + } + } + + // RLS가 막으면 error 없이 0건 삭제로 조용히 끝날 수 있어(예: 삭제 도중 다른 곳에서 소유권이 + // 넘어간 경우), select로 실제 삭제된 row가 있는지까지 확인한다. + const { data: deleted, error } = await supabase + .from('workspaces') + .delete() + .eq('id', workspaceId) + .eq('owner_id', userId) + .select('id') + .maybeSingle(); + + if (error || !deleted) { + console.error('[deleteWorkspace] delete 실패:', error); + throw new Error('워크스페이스를 삭제할 권한이 없거나 이미 삭제됐어요.'); + } + + revalidatePath('/workspaces'); +} diff --git a/src/entities/workspace/api/transfer-ownership.ts b/src/entities/workspace/api/transfer-ownership.ts new file mode 100644 index 0000000..4f35b47 --- /dev/null +++ b/src/entities/workspace/api/transfer-ownership.ts @@ -0,0 +1,34 @@ +'use server'; + +// 워크스페이스 소유권 이전 서버액션 — transfer_workspace_ownership RPC 호출 +import { revalidatePath } from 'next/cache'; +import { z } from 'zod'; +import { createSupabaseServerClient } from '@/shared/api/supabase/server'; + +const transferOwnershipInputSchema = z.object({ + workspaceId: z.guid(), + newOwnerUserId: z.guid(), +}); + +export type TransferOwnershipInput = z.infer; + +export async function transferOwnership(input: TransferOwnershipInput): Promise { + const parsed = transferOwnershipInputSchema.safeParse(input); + if (!parsed.success) { + throw new Error('입력값이 올바르지 않습니다'); + } + + const supabase = await createSupabaseServerClient(); + const { error } = await supabase.rpc('transfer_workspace_ownership', { + p_workspace_id: parsed.data.workspaceId, + p_new_owner_id: parsed.data.newOwnerUserId, + }); + + if (error) { + // 자기 자신 지정, 비멤버 대상 등 RPC가 던진 메시지를 그대로 전달한다. + console.error('[transferOwnership] RPC 실패:', error); + throw new Error(error.message || '소유권 이전에 실패했습니다. 잠시 후 다시 시도해주세요.'); + } + + revalidatePath(`/workspaces/${parsed.data.workspaceId}/settings`); +} diff --git a/src/entities/workspace/index.ts b/src/entities/workspace/index.ts index d6de516..0915d59 100644 --- a/src/entities/workspace/index.ts +++ b/src/entities/workspace/index.ts @@ -25,11 +25,10 @@ export { export { getMyWorkspaces } from './api/get-my-workspaces'; export { useMyWorkspaces, myWorkspacesQueryKey } from './api/use-my-workspaces'; export { createWorkspace } from './api/create-workspace'; -export { - updateWorkspaceInfo, - type UpdateWorkspaceInfoInput, -} from './api/update-workspace-info'; +export { updateWorkspaceInfo, type UpdateWorkspaceInfoInput } from './api/update-workspace-info'; export { joinWorkspaceByInviteCode } from './api/join-workspace-by-invite-code'; +export { transferOwnership, type TransferOwnershipInput } from './api/transfer-ownership'; +export { deleteWorkspace, type DeleteWorkspaceInput } from './api/delete-workspace'; export { sendInviteEmail, type SendInviteEmailInput } from './api/send-invite-email'; export { setWorkspaceInviteEnabled, diff --git a/src/features/manage-member-profile/ui/MemberProfileForm.tsx b/src/features/manage-member-profile/ui/MemberProfileForm.tsx index 0028cb6..e0d6432 100644 --- a/src/features/manage-member-profile/ui/MemberProfileForm.tsx +++ b/src/features/manage-member-profile/ui/MemberProfileForm.tsx @@ -2,6 +2,7 @@ // 프로필 탭 — 닉네임 수정 및 팀 탈퇴 import { useState } from 'react'; +import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { Loader2 } from 'lucide-react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; @@ -18,17 +19,21 @@ import { workspaceMembersByWorkspaceQueryKey, leaveWorkspace, } from '@/entities/workspace-member'; +import { myWorkspacesQueryKey } from '@/entities/workspace'; interface MemberProfileFormProps { workspaceId: string; initialNickname: string; isOwner: boolean; + // owner의 탈퇴 안내 문구 분기용 — 소유권 이전은 팀 관리 탭, 워크스페이스 삭제는 워크스페이스 관리 탭에 있다. + hasOtherMembers: boolean; } export function MemberProfileForm({ workspaceId, initialNickname, isOwner, + hasOtherMembers, }: MemberProfileFormProps) { const router = useRouter(); const queryClient = useQueryClient(); @@ -76,6 +81,8 @@ export function MemberProfileForm({ setIsLeaving(true); try { await leaveWorkspace({ workspaceId }); + // 목록은 client-side react-query 캐시라 별도로 무효화해야 즉시 사라진다. + await queryClient.invalidateQueries({ queryKey: myWorkspacesQueryKey }); router.push('/workspaces'); // 성공 시 페이지 이동으로 언마운트되므로 isLeaving을 리셋하지 않는다(버튼 깜빡임 방지) } catch (error) { @@ -126,52 +133,92 @@ export function MemberProfileForm({

팀 탈퇴

-

- {isOwner - ? '워크스페이스 소유자는 팀을 탈퇴할 수 없어요. 소유권을 이전한 후 탈퇴해주세요.' - : '탈퇴하면 이 워크스페이스에서 나가게 되며, 데이터를 복구할 수 없어요.'} -

- - - !isLeaving && setShowLeaveConfirm(open)} - > - - 정말 탈퇴하시겠어요? - - 탈퇴하면 이 워크스페이스에서 나가게 되며, -
- 데이터를 복구할 수 없어요. -
- - - - -
-
+ {!isOwner && ( + <> +

+ 탈퇴하면 이 워크스페이스에서 나가게 되며, 데이터를 복구할 수 없어요. +

+ + + + !isLeaving && setShowLeaveConfirm(open)} + > + + 정말 탈퇴하시겠어요? + + 탈퇴하면 이 워크스페이스에서 나가게 되며, +
+ 데이터를 복구할 수 없어요. +
+ + + + +
+
+ + )} + + {isOwner && ( + <> +

+ {hasOtherMembers ? ( + <> + 워크스페이스 소유자는 팀을 탈퇴할 수 없어요.{' '} + + 팀 관리 탭 + + 에서 소유권을 이전해주세요. + + ) : ( + <> + 소유자는 팀을 탈퇴할 수 없어요. 더 이상 워크스페이스를 사용하지 않는다면{' '} + + 워크스페이스 관리 탭 + + 에서 삭제할 수 있어요. + + )} +

+ + + + )}
); diff --git a/src/features/manage-workspace-info/index.ts b/src/features/manage-workspace-info/index.ts index cbf1045..8d42193 100644 --- a/src/features/manage-workspace-info/index.ts +++ b/src/features/manage-workspace-info/index.ts @@ -1 +1,2 @@ export { WorkspaceInfoForm } from './ui/WorkspaceInfoForm'; +export { DeleteWorkspaceSection } from './ui/DeleteWorkspaceSection'; diff --git a/src/features/manage-workspace-info/ui/DeleteWorkspaceDialog.tsx b/src/features/manage-workspace-info/ui/DeleteWorkspaceDialog.tsx new file mode 100644 index 0000000..34e5457 --- /dev/null +++ b/src/features/manage-workspace-info/ui/DeleteWorkspaceDialog.tsx @@ -0,0 +1,108 @@ +'use client'; + +// 워크스페이스 삭제 확인 다이얼로그 — 이름을 정확히 입력해야만 삭제 버튼이 활성화된다. +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { Loader2 } from 'lucide-react'; +import { useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { deleteWorkspace, myWorkspacesQueryKey } from '@/entities/workspace'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogTitle, +} from '@/shared/ui/dialog'; + +interface DeleteWorkspaceDialogProps { + workspaceId: string; + workspaceName: string; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function DeleteWorkspaceDialog({ + workspaceId, + workspaceName, + open, + onOpenChange, +}: DeleteWorkspaceDialogProps) { + const router = useRouter(); + const queryClient = useQueryClient(); + const [confirmText, setConfirmText] = useState(''); + const [isDeleting, setIsDeleting] = useState(false); + const canDelete = confirmText === workspaceName && !isDeleting; + + const handleDelete = async () => { + if (!canDelete) return; + setIsDeleting(true); + try { + await deleteWorkspace({ workspaceId }); + // 목록은 client-side react-query 캐시라 별도로 무효화해야 즉시 사라진다. + await queryClient.invalidateQueries({ queryKey: myWorkspacesQueryKey }); + router.push('/workspaces'); + // 성공 시 페이지 이동으로 언마운트되므로 isDeleting을 리셋하지 않는다(버튼 깜빡임 방지) + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : '워크스페이스 삭제에 실패했어요. 잠시 후 다시 시도해주세요.', + ); + setIsDeleting(false); + } + }; + + return ( + { + if (isDeleting) return; + setConfirmText(''); + onOpenChange(next); + }} + > + + 워크스페이스를 삭제하시겠어요? + + 워크스페이스의 모든 데이터(업무, 일정, 공지, 자료 등)가 함께 삭제되며, 되돌릴 수 없어요. + + + + + + + + + + + ); +} diff --git a/src/features/manage-workspace-info/ui/DeleteWorkspaceSection.tsx b/src/features/manage-workspace-info/ui/DeleteWorkspaceSection.tsx new file mode 100644 index 0000000..df03b23 --- /dev/null +++ b/src/features/manage-workspace-info/ui/DeleteWorkspaceSection.tsx @@ -0,0 +1,42 @@ +'use client'; + +// 워크스페이스 삭제 섹션 — owner면 다른 멤버 존재 여부와 무관하게 항상 노출한다. +import { useState } from 'react'; +import { DeleteWorkspaceDialog } from './DeleteWorkspaceDialog'; + +interface DeleteWorkspaceSectionProps { + workspaceId: string; + workspaceName: string; +} + +export function DeleteWorkspaceSection({ + workspaceId, + workspaceName, +}: DeleteWorkspaceSectionProps) { + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + + return ( +
+

워크스페이스 삭제

+

+ 모든 멤버가 워크스페이스에서 제외되고, 업무·일정·공지·자료 등 모든 데이터가 함께 사라지며 + 되돌릴 수 없어요. +

+ + + + +
+ ); +} diff --git a/src/features/manage-workspace-members/index.ts b/src/features/manage-workspace-members/index.ts index b1780a0..55ee04b 100644 --- a/src/features/manage-workspace-members/index.ts +++ b/src/features/manage-workspace-members/index.ts @@ -1,4 +1,5 @@ export { MemberManagementPanel } from './ui/MemberManagementPanel'; export { MemberInviteSection } from './ui/MemberInviteSection'; export { MemberList } from './ui/MemberList'; +export { OwnershipTransferSection } from './ui/OwnershipTransferSection'; export { useMemberManagement, type InviteMode } from './model/use-member-management'; diff --git a/src/features/manage-workspace-members/ui/OwnershipTransferSection.tsx b/src/features/manage-workspace-members/ui/OwnershipTransferSection.tsx new file mode 100644 index 0000000..e02594d --- /dev/null +++ b/src/features/manage-workspace-members/ui/OwnershipTransferSection.tsx @@ -0,0 +1,42 @@ +'use client'; + +// 소유권 이전 섹션 — owner이고 다른 멤버가 있을 때만 팀 관리 탭에 노출한다. +import { useState } from 'react'; +import type { WorkspaceMember } from '@/entities/workspace-member'; +import { TransferOwnershipDialog } from './TransferOwnershipDialog'; + +interface OwnershipTransferSectionProps { + workspaceId: string; + otherMembers: WorkspaceMember[]; +} + +export function OwnershipTransferSection({ + workspaceId, + otherMembers, +}: OwnershipTransferSectionProps) { + const [showTransferDialog, setShowTransferDialog] = useState(false); + + return ( +
+

소유권 이전

+

+ 다른 멤버에게 워크스페이스 소유권을 넘길 수 있어요. 이전 후에도 계속 멤버로 남을 수 있어요. +

+ + + + +
+ ); +} diff --git a/src/features/manage-workspace-members/ui/TransferOwnershipDialog.tsx b/src/features/manage-workspace-members/ui/TransferOwnershipDialog.tsx new file mode 100644 index 0000000..d92923a --- /dev/null +++ b/src/features/manage-workspace-members/ui/TransferOwnershipDialog.tsx @@ -0,0 +1,98 @@ +'use client'; + +// 소유권 이전 확인 다이얼로그 — 다른 멤버 중 한 명을 선택해 소유권을 넘긴다. +import { useState } from 'react'; +import { Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { transferOwnership } from '@/entities/workspace'; +import type { WorkspaceMember } from '@/entities/workspace-member'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogTitle, +} from '@/shared/ui/dialog'; + +interface TransferOwnershipDialogProps { + workspaceId: string; + otherMembers: WorkspaceMember[]; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function TransferOwnershipDialog({ + workspaceId, + otherMembers, + open, + onOpenChange, +}: TransferOwnershipDialogProps) { + const [selectedUserId, setSelectedUserId] = useState(otherMembers[0]?.userId ?? ''); + const [isTransferring, setIsTransferring] = useState(false); + + const handleTransfer = async () => { + if (!selectedUserId) return; + setIsTransferring(true); + try { + await transferOwnership({ workspaceId, newOwnerUserId: selectedUserId }); + // router.refresh()는 사이드바 role 표시까지 확실히 갱신하지 못해 하드 리로드로 대체한다. + window.location.reload(); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : '소유권 이전에 실패했어요. 잠시 후 다시 시도해주세요.', + ); + setIsTransferring(false); + } + }; + + return ( + !isTransferring && onOpenChange(next)}> + + 소유권을 이전할 멤버를 선택하세요 + + 이전 후에는 되돌릴 수 없으며, 선택한 멤버가 새 워크스페이스 소유자가 돼요. + + +
+ {otherMembers.map((member) => ( + + ))} +
+ + + + + +
+
+ ); +} diff --git a/src/shared/model/database.types.ts b/src/shared/model/database.types.ts index d99168c..0fb62a9 100644 --- a/src/shared/model/database.types.ts +++ b/src/shared/model/database.types.ts @@ -915,6 +915,10 @@ export type Database = { } Returns: undefined } + transfer_workspace_ownership: { + Args: { p_new_owner_id: string; p_workspace_id: string } + Returns: undefined + } update_task_board: { Args: { p_tasks: Json; p_workspace_id: string } Returns: undefined diff --git a/src/views/settings/model/settings-tab.ts b/src/views/settings/model/settings-tab.ts index 3c9d394..73dfe36 100644 --- a/src/views/settings/model/settings-tab.ts +++ b/src/views/settings/model/settings-tab.ts @@ -7,9 +7,9 @@ export interface SettingsTab { } export const SETTINGS_TABS: SettingsTab[] = [ - { key: 'workspace', label: '워크스페이스 정보' }, - { key: 'members', label: '팀원 관리' }, - { key: 'profile', label: '프로필' }, + { key: 'workspace', label: '워크스페이스 관리' }, + { key: 'members', label: '팀 관리' }, + { key: 'profile', label: '프로필 설정' }, ]; export const DEFAULT_SETTINGS_TAB: SettingsTabKey = 'workspace'; diff --git a/src/views/settings/ui/SettingsView.tsx b/src/views/settings/ui/SettingsView.tsx index 667f73e..ca655ce 100644 --- a/src/views/settings/ui/SettingsView.tsx +++ b/src/views/settings/ui/SettingsView.tsx @@ -5,8 +5,11 @@ // 각 탭 내용은 feature 컴포넌트에 위임하고, 표시 데이터는 서버에서 주입받는다. import type { Workspace } from '@/entities/workspace'; import type { WorkspaceMember } from '@/entities/workspace-member'; -import { WorkspaceInfoForm } from '@/features/manage-workspace-info'; -import { MemberManagementPanel } from '@/features/manage-workspace-members'; +import { WorkspaceInfoForm, DeleteWorkspaceSection } from '@/features/manage-workspace-info'; +import { + MemberManagementPanel, + OwnershipTransferSection, +} from '@/features/manage-workspace-members'; import { MemberProfileForm } from '@/features/manage-member-profile'; import { plusJakartaSans } from '@/shared/lib/fonts'; import type { SettingsTabKey } from '../model/settings-tab'; @@ -31,6 +34,8 @@ export function SettingsView({ }: SettingsViewProps) { // 워크스페이스 정보 수정은 RLS상 소유자만 가능하므로, 현재 사용자의 역할로 편집 권한을 판단한다. const isOwner = members.find((member) => member.userId === currentUserId)?.role === 'owner'; + const otherMembers = members.filter((member) => member.userId !== currentUserId); + const hasOtherMembers = otherMembers.length > 0; return (
@@ -39,21 +44,34 @@ export function SettingsView({
- {activeTab === 'workspace' && } + {activeTab === 'workspace' && ( +
+ + {isOwner && ( + + )} +
+ )} {activeTab === 'members' && ( - +
+ + {isOwner && hasOtherMembers && ( + + )} +
)} {activeTab === 'profile' && ( )}
diff --git a/supabase/migrations/20260722000000_create_workspace_ownership_transfer_rpc.sql b/supabase/migrations/20260722000000_create_workspace_ownership_transfer_rpc.sql new file mode 100644 index 0000000..7da408f --- /dev/null +++ b/supabase/migrations/20260722000000_create_workspace_ownership_transfer_rpc.sql @@ -0,0 +1,63 @@ +-- 워크스페이스 소유권 이전 RPC — 현재 소유자만, 같은 워크스페이스의 다른 멤버에게만 가능하다. +create or replace function public.transfer_workspace_ownership( + p_workspace_id uuid, + p_new_owner_id uuid +) +returns void +language plpgsql +security definer +set search_path = public, pg_temp +as $$ +declare + v_caller uuid := auth.uid(); + v_current_owner_id uuid; +begin + if v_caller is null then + raise exception '인증된 사용자만 소유권을 이전할 수 있습니다.' + using errcode = '28000'; + end if; + + if p_new_owner_id = v_caller then + raise exception '자기 자신에게는 소유권을 이전할 수 없습니다.'; + end if; + + -- 동시 이전/삭제 요청과 겹치지 않도록 워크스페이스 row를 잠근다. + select owner_id into v_current_owner_id + from workspaces + where id = p_workspace_id + for update; + + if not found then + raise exception '워크스페이스를 찾을 수 없습니다.'; + end if; + + if v_current_owner_id != v_caller then + raise exception '워크스페이스 소유자만 소유권을 이전할 수 있습니다.' + using errcode = '42501'; + end if; + + -- 대상 멤버 row도 잠가, 확인과 role 변경 사이에 본인이 탈퇴(row 삭제)하는 걸 막는다. + perform 1 from workspace_members + where workspace_id = p_workspace_id and user_id = p_new_owner_id + for update; + + if not found then + raise exception '대상 사용자는 이 워크스페이스의 멤버가 아닙니다.'; + end if; + + update workspace_members + set role = 'owner' + where workspace_id = p_workspace_id and user_id = p_new_owner_id; + + update workspace_members + set role = 'member' + where workspace_id = p_workspace_id and user_id = v_caller; + + update workspaces + set owner_id = p_new_owner_id + where id = p_workspace_id; +end; +$$; + +revoke all on function public.transfer_workspace_ownership(uuid, uuid) from public; +grant execute on function public.transfer_workspace_ownership(uuid, uuid) to authenticated; \ No newline at end of file From 6be9369620b9fd7ce12bf35af9a7a1aa28842c4f Mon Sep 17 00:00:00 2001 From: 0011810 Date: Thu, 23 Jul 2026 11:37:09 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=EC=9B=8C=ED=81=AC=EC=8A=A4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=EC=95=88=EC=A0=84=20=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?=ED=9D=90=EB=A6=84=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workspace/api/delete-workspace.ts | 118 +++--- .../ui/MemberProfileForm.tsx | 10 +- .../ui/DeleteWorkspaceDialog.tsx | 10 +- .../ui/TransferOwnershipDialog.tsx | 26 +- src/shared/model/database.types.ts | 44 +++ ...3000000_create_workspace_deletion_jobs.sql | 337 ++++++++++++++++++ 6 files changed, 490 insertions(+), 55 deletions(-) create mode 100644 supabase/migrations/20260723000000_create_workspace_deletion_jobs.sql diff --git a/src/entities/workspace/api/delete-workspace.ts b/src/entities/workspace/api/delete-workspace.ts index 2b0d10b..0ac595d 100644 --- a/src/entities/workspace/api/delete-workspace.ts +++ b/src/entities/workspace/api/delete-workspace.ts @@ -1,24 +1,31 @@ 'use server'; -// 워크스페이스 삭제 서버액션 — owner면 멤버 수와 무관하게 실행 가능 (RLS: workspaces_delete_owner) -// Storage 파일은 FK 캐스케이드 대상이 아니라 workspaces row 삭제 전에 먼저 지워야 한다(이후엔 RLS가 막음). +// 워크스페이스 삭제 서버액션 — owner면 멤버 수와 무관하게 실행 가능 +// begin_workspace_deletion(선점) → mark_workspace_deletion_in_progress(reserved→deleting 전환, 이 시점부터 +// 자동 만료 없음 + 신규 업로드 확실히 차단) → Storage 목록 조회·배치 삭제(목록이 빌 때까지 반복) → +// finalize_workspace_deletion(실제 DB 삭제) 순서로 진행한다. deleting 전환 이후에만 목록을 조회하므로 그 +// 사이에 새 파일이 올라와 누락되는 일이 없다. workspace row와 owner 멤버십은 finalize 전까지 그대로 +// 남아있어 일반 유저 세션으로도 Storage RLS를 그대로 통과하므로 admin 클라이언트가 필요 없다. import { revalidatePath } from 'next/cache'; import { z } from 'zod'; -import { getCurrentUserId } from '@/shared/api/supabase/current-user'; import { createSupabaseServerClient } from '@/shared/api/supabase/server'; const WORKSPACE_RESOURCES_BUCKET = 'workspace-resources'; const STORAGE_LIST_PAGE_SIZE = 1000; +const DELETE_FAILED_MESSAGE = '워크스페이스 삭제에 실패했어요. 잠시 후 다시 시도해주세요.'; + const deleteWorkspaceInputSchema = z.object({ workspaceId: z.guid(), }); export type DeleteWorkspaceInput = z.infer; +type ServerSupabaseClient = Awaited>; + // .list()는 한 번에 최대 1000개만 반환하므로, offset을 늘려가며 전체 파일 목록을 모은다. async function listAllStorageFileNames( - supabase: Awaited>, + supabase: ServerSupabaseClient, workspaceId: string, ): Promise { const names: string[] = []; @@ -51,6 +58,39 @@ async function listAllStorageFileNames( return names; } +async function removeStorageFiles( + supabase: ServerSupabaseClient, + workspaceId: string, + fileNames: string[], +): Promise { + const paths = fileNames.map((name) => `${workspaceId}/${name}`); + + // remove()도 한 번에 최대 1000개까지만 처리되므로, list()와 동일한 크기로 나눠서 삭제한다. + for (let index = 0; index < paths.length; index += STORAGE_LIST_PAGE_SIZE) { + const batch = paths.slice(index, index + STORAGE_LIST_PAGE_SIZE); + const { error } = await supabase.storage.from(WORKSPACE_RESOURCES_BUCKET).remove(batch); + + if (error) { + throw error; + } + } +} + +// deleting 전환 이후 신규 업로드는 막혀있지만, 혹시 남는 파일이 있을 수 있으니 목록이 빌 때까지 +// 조회·삭제를 반복해 고아 파일 가능성을 줄인다. +async function cleanupWorkspaceStorage( + supabase: ServerSupabaseClient, + workspaceId: string, +): Promise { + while (true) { + const fileNames = await listAllStorageFileNames(supabase, workspaceId); + if (fileNames.length === 0) { + break; + } + await removeStorageFiles(supabase, workspaceId, fileNames); + } +} + export async function deleteWorkspace(input: DeleteWorkspaceInput): Promise { const parsed = deleteWorkspaceInputSchema.safeParse(input); if (!parsed.success) { @@ -58,60 +98,44 @@ export async function deleteWorkspace(input: DeleteWorkspaceInput): Promise `${workspaceId}/${name}`); - for (let index = 0; index < paths.length; index += STORAGE_LIST_PAGE_SIZE) { - const batch = paths.slice(index, index + STORAGE_LIST_PAGE_SIZE); - const { error: removeError } = await supabase.storage - .from(WORKSPACE_RESOURCES_BUCKET) - .remove(batch); - - if (removeError) { - console.error('[deleteWorkspace] Storage 파일 삭제 실패:', removeError); - throw new Error('워크스페이스 삭제에 실패했어요. 잠시 후 다시 시도해주세요.'); - } - } + const { error: finalizeError } = await supabase.rpc('finalize_workspace_deletion', { + p_workspace_id: workspaceId, + p_deletion_token: token, + }); - // RLS가 막으면 error 없이 0건 삭제로 조용히 끝날 수 있어(예: 삭제 도중 다른 곳에서 소유권이 - // 넘어간 경우), select로 실제 삭제된 row가 있는지까지 확인한다. - const { data: deleted, error } = await supabase - .from('workspaces') - .delete() - .eq('id', workspaceId) - .eq('owner_id', userId) - .select('id') - .maybeSingle(); - - if (error || !deleted) { - console.error('[deleteWorkspace] delete 실패:', error); - throw new Error('워크스페이스를 삭제할 권한이 없거나 이미 삭제됐어요.'); + if (finalizeError) { + console.error('[deleteWorkspace] 삭제 완료 실패:', finalizeError); + throw new Error(finalizeError.message || DELETE_FAILED_MESSAGE); } revalidatePath('/workspaces'); diff --git a/src/features/manage-member-profile/ui/MemberProfileForm.tsx b/src/features/manage-member-profile/ui/MemberProfileForm.tsx index e0d6432..7528167 100644 --- a/src/features/manage-member-profile/ui/MemberProfileForm.tsx +++ b/src/features/manage-member-profile/ui/MemberProfileForm.tsx @@ -172,9 +172,17 @@ export function MemberProfileForm({ type="button" onClick={handleLeave} disabled={isLeaving} + aria-busy={isLeaving} className="flex h-10 items-center justify-center rounded-2xl bg-red-500 px-5 text-sm font-bold text-white hover:bg-red-600 disabled:opacity-50" > - {isLeaving ? : '탈퇴하기'} + {isLeaving ? ( + <> +