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
144 changes: 144 additions & 0 deletions src/entities/workspace/api/delete-workspace.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
'use server';

// 워크스페이스 삭제 서버액션 — 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 { 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<typeof deleteWorkspaceInputSchema>;

type ServerSupabaseClient = Awaited<ReturnType<typeof createSupabaseServerClient>>;

// .list()는 한 번에 최대 1000개만 반환하므로, offset을 늘려가며 전체 파일 목록을 모은다.
async function listAllStorageFileNames(
supabase: ServerSupabaseClient,
workspaceId: string,
): Promise<string[]> {
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;
}

// id가 null인 항목은 폴더 placeholder라 remove() 대상이 아니다 — 업로드 경로가 항상 평면 구조라
// 지금은 나타나지 않지만, 혹시 남아있으면 cleanupWorkspaceStorage의 while 루프가 끝나지 않으므로 걸러낸다.
names.push(...page.filter((file) => file.id !== null).map((file) => file.name));

if (page.length < STORAGE_LIST_PAGE_SIZE) {
break;
}
offset += STORAGE_LIST_PAGE_SIZE;
}

return names;
}

async function removeStorageFiles(
supabase: ServerSupabaseClient,
workspaceId: string,
fileNames: string[],
): Promise<void> {
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<void> {
while (true) {
const fileNames = await listAllStorageFileNames(supabase, workspaceId);
if (fileNames.length === 0) {
break;
}
await removeStorageFiles(supabase, workspaceId, fileNames);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export async function deleteWorkspace(input: DeleteWorkspaceInput): Promise<void> {
const parsed = deleteWorkspaceInputSchema.safeParse(input);
if (!parsed.success) {
throw new Error('입력값이 올바르지 않습니다');
}

const { workspaceId } = parsed.data;
const supabase = await createSupabaseServerClient();

const { data: token, error: beginError } = await supabase.rpc('begin_workspace_deletion', {
p_workspace_id: workspaceId,
});

if (beginError || !token) {
console.error('[deleteWorkspace] 삭제 선점 실패:', beginError);
throw new Error(beginError?.message || '워크스페이스를 삭제할 권한이 없거나 이미 삭제됐어요.');
}

const { error: markError } = await supabase.rpc('mark_workspace_deletion_in_progress', {
p_workspace_id: workspaceId,
p_deletion_token: token,
});

if (markError) {
console.error('[deleteWorkspace] 삭제 진행 전환 실패:', markError);
throw new Error(markError.message || DELETE_FAILED_MESSAGE);
}

// 여기서부터는 deleting 상태라 자동 만료되지 않는다 — 실패해도 같은 owner가 재시도하면
// begin_workspace_deletion이 같은 token을 돌려주고, 이미 지워진 파일은 목록에서 빠지므로 멱등적으로 이어진다.
try {
await cleanupWorkspaceStorage(supabase, workspaceId);
} catch (storageError) {
console.error('[deleteWorkspace] Storage 정리 실패:', storageError);
throw new Error(DELETE_FAILED_MESSAGE);
}

const { error: finalizeError } = await supabase.rpc('finalize_workspace_deletion', {
p_workspace_id: workspaceId,
p_deletion_token: token,
});

if (finalizeError) {
console.error('[deleteWorkspace] 삭제 완료 실패:', finalizeError);
throw new Error(finalizeError.message || DELETE_FAILED_MESSAGE);
}

revalidatePath('/workspaces');
}
34 changes: 34 additions & 0 deletions src/entities/workspace/api/transfer-ownership.ts
Original file line number Diff line number Diff line change
@@ -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<typeof transferOwnershipInputSchema>;

export async function transferOwnership(input: TransferOwnershipInput): Promise<void> {
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`);
}
7 changes: 3 additions & 4 deletions src/entities/workspace/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
145 changes: 100 additions & 45 deletions src/features/manage-member-profile/ui/MemberProfileForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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();
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -126,52 +133,100 @@ export function MemberProfileForm({

<section className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<h2 className="text-base font-bold text-slate-950">팀 탈퇴</h2>
<p className="mt-1 text-sm text-slate-500">
{isOwner
? '워크스페이스 소유자는 팀을 탈퇴할 수 없어요. 소유권을 이전한 후 탈퇴해주세요.'
: '탈퇴하면 이 워크스페이스에서 나가게 되며, 데이터를 복구할 수 없어요.'}
</p>

<button
type="button"
disabled={isOwner}
onClick={() => setShowLeaveConfirm(true)}
className="mt-4 h-10 rounded-2xl border border-slate-200 px-5 text-sm font-bold text-slate-600 hover:bg-slate-50 hover:text-red-500 disabled:cursor-not-allowed disabled:opacity-40"
>
탈퇴하기
</button>

<Dialog
open={showLeaveConfirm}
onOpenChange={(open) => !isLeaving && setShowLeaveConfirm(open)}
>
<DialogContent className="p-6 sm:max-w-[380px]">
<DialogTitle className="font-bold">정말 탈퇴하시겠어요?</DialogTitle>
<DialogDescription>
탈퇴하면 이 워크스페이스에서 나가게 되며,
<br />
데이터를 복구할 수 없어요.
</DialogDescription>
<DialogFooter className="mx-0 mb-0 border-t-0 bg-transparent p-0 pt-2">
<button
type="button"
onClick={() => setShowLeaveConfirm(false)}
disabled={isLeaving}
className="h-10 rounded-2xl border border-slate-200 px-5 text-sm font-bold text-slate-600 hover:bg-slate-50 disabled:opacity-50"
>
취소
</button>
<button
type="button"
onClick={handleLeave}
disabled={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 ? <Loader2 size={18} className="animate-spin" /> : '탈퇴하기'}
</button>
</DialogFooter>
</DialogContent>
</Dialog>
{!isOwner && (
<>
<p className="mt-1 text-sm text-slate-500">
탈퇴하면 이 워크스페이스에서 나가게 되며, 데이터를 복구할 수 없어요.
</p>

<button
type="button"
onClick={() => setShowLeaveConfirm(true)}
className="mt-4 h-10 rounded-2xl border border-slate-200 px-5 text-sm font-bold text-slate-600 hover:bg-slate-50 hover:text-red-500"
>
탈퇴하기
</button>

<Dialog
open={showLeaveConfirm}
onOpenChange={(open) => !isLeaving && setShowLeaveConfirm(open)}
>
<DialogContent className="p-6 sm:max-w-[380px]">
<DialogTitle className="font-bold">정말 탈퇴하시겠어요?</DialogTitle>
<DialogDescription>
탈퇴하면 이 워크스페이스에서 나가게 되며,
<br />
데이터를 복구할 수 없어요.
</DialogDescription>
<DialogFooter className="mx-0 mb-0 border-t-0 bg-transparent p-0 pt-2">
<button
type="button"
onClick={() => setShowLeaveConfirm(false)}
disabled={isLeaving}
className="h-10 rounded-2xl border border-slate-200 px-5 text-sm font-bold text-slate-600 hover:bg-slate-50 disabled:opacity-50"
>
취소
</button>
<button
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 ? (
<>
<Loader2 size={18} className="animate-spin" aria-hidden="true" />
<span className="sr-only">탈퇴 중</span>
</>
) : (
'탈퇴하기'
)}
</button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)}

{isOwner && (
<>
<p className="mt-1 text-sm text-slate-500">
{hasOtherMembers ? (
<>
워크스페이스 소유자는 팀을 탈퇴할 수 없어요.{' '}
<Link
href="?tab=members"
className="font-bold text-[var(--color-brand)] hover:underline"
>
팀 관리 탭
</Link>
에서 소유권을 이전해주세요.
</>
) : (
<>
소유자는 팀을 탈퇴할 수 없어요. 더 이상 워크스페이스를 사용하지 않는다면{' '}
<Link
href="?tab=workspace"
className="font-bold text-[var(--color-brand)] hover:underline"
>
워크스페이스 관리 탭
</Link>
에서 삭제할 수 있어요.
</>
)}
</p>

<button
type="button"
disabled
className="mt-4 h-10 rounded-2xl border border-slate-200 px-5 text-sm font-bold text-slate-600 disabled:cursor-not-allowed disabled:opacity-40"
>
탈퇴하기
</button>
</>
)}
</section>
</div>
);
Expand Down
1 change: 1 addition & 0 deletions src/features/manage-workspace-info/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { WorkspaceInfoForm } from './ui/WorkspaceInfoForm';
export { DeleteWorkspaceSection } from './ui/DeleteWorkspaceSection';
Loading