feat: implement organization deletion with cascading cleanup of associated resources
This commit is contained in:
@@ -5,7 +5,7 @@ from typing import Any, Sequence
|
|||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy import delete, func
|
from sqlalchemy import delete, func, update
|
||||||
from sqlmodel import col, select
|
from sqlmodel import col, select
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
@@ -14,13 +14,25 @@ from app.core.auth import AuthContext, get_auth_context
|
|||||||
from app.core.time import utcnow
|
from app.core.time import utcnow
|
||||||
from app.db.pagination import paginate
|
from app.db.pagination import paginate
|
||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
|
from app.models.activity_events import ActivityEvent
|
||||||
|
from app.models.agents import Agent
|
||||||
|
from app.models.approvals import Approval
|
||||||
|
from app.models.board_group_memory import BoardGroupMemory
|
||||||
|
from app.models.board_groups import BoardGroup
|
||||||
|
from app.models.board_memory import BoardMemory
|
||||||
|
from app.models.board_onboarding import BoardOnboardingSession
|
||||||
from app.models.boards import Board
|
from app.models.boards import Board
|
||||||
|
from app.models.gateways import Gateway
|
||||||
from app.models.organization_board_access import OrganizationBoardAccess
|
from app.models.organization_board_access import OrganizationBoardAccess
|
||||||
from app.models.organization_invite_board_access import OrganizationInviteBoardAccess
|
from app.models.organization_invite_board_access import OrganizationInviteBoardAccess
|
||||||
from app.models.organization_invites import OrganizationInvite
|
from app.models.organization_invites import OrganizationInvite
|
||||||
from app.models.organization_members import OrganizationMember
|
from app.models.organization_members import OrganizationMember
|
||||||
from app.models.organizations import Organization
|
from app.models.organizations import Organization
|
||||||
|
from app.models.task_dependencies import TaskDependency
|
||||||
|
from app.models.task_fingerprints import TaskFingerprint
|
||||||
|
from app.models.tasks import Task
|
||||||
from app.models.users import User
|
from app.models.users import User
|
||||||
|
from app.schemas.common import OkResponse
|
||||||
from app.schemas.organizations import (
|
from app.schemas.organizations import (
|
||||||
OrganizationActiveUpdate,
|
OrganizationActiveUpdate,
|
||||||
OrganizationBoardAccessRead,
|
OrganizationBoardAccessRead,
|
||||||
@@ -153,6 +165,82 @@ async def get_my_org(ctx: OrganizationContext = Depends(require_org_member)) ->
|
|||||||
return OrganizationRead.model_validate(ctx.organization, from_attributes=True)
|
return OrganizationRead.model_validate(ctx.organization, from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/me", response_model=OkResponse)
|
||||||
|
async def delete_my_org(
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
ctx: OrganizationContext = Depends(require_org_admin),
|
||||||
|
) -> OkResponse:
|
||||||
|
if ctx.member.role != "owner":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Only organization owners can delete organizations",
|
||||||
|
)
|
||||||
|
|
||||||
|
org_id = ctx.organization.id
|
||||||
|
board_ids = select(Board.id).where(col(Board.organization_id) == org_id)
|
||||||
|
task_ids = select(Task.id).where(col(Task.board_id).in_(board_ids))
|
||||||
|
agent_ids = select(Agent.id).where(col(Agent.board_id).in_(board_ids))
|
||||||
|
member_ids = select(OrganizationMember.id).where(
|
||||||
|
col(OrganizationMember.organization_id) == org_id
|
||||||
|
)
|
||||||
|
invite_ids = select(OrganizationInvite.id).where(
|
||||||
|
col(OrganizationInvite.organization_id) == org_id
|
||||||
|
)
|
||||||
|
group_ids = select(BoardGroup.id).where(col(BoardGroup.organization_id) == org_id)
|
||||||
|
|
||||||
|
await session.execute(delete(ActivityEvent).where(col(ActivityEvent.task_id).in_(task_ids)))
|
||||||
|
await session.execute(delete(ActivityEvent).where(col(ActivityEvent.agent_id).in_(agent_ids)))
|
||||||
|
await session.execute(delete(TaskDependency).where(col(TaskDependency.board_id).in_(board_ids)))
|
||||||
|
await session.execute(
|
||||||
|
delete(TaskFingerprint).where(col(TaskFingerprint.board_id).in_(board_ids))
|
||||||
|
)
|
||||||
|
await session.execute(delete(Approval).where(col(Approval.board_id).in_(board_ids)))
|
||||||
|
await session.execute(delete(BoardMemory).where(col(BoardMemory.board_id).in_(board_ids)))
|
||||||
|
await session.execute(
|
||||||
|
delete(BoardOnboardingSession).where(col(BoardOnboardingSession.board_id).in_(board_ids))
|
||||||
|
)
|
||||||
|
await session.execute(
|
||||||
|
delete(OrganizationBoardAccess).where(col(OrganizationBoardAccess.board_id).in_(board_ids))
|
||||||
|
)
|
||||||
|
await session.execute(
|
||||||
|
delete(OrganizationInviteBoardAccess).where(
|
||||||
|
col(OrganizationInviteBoardAccess.board_id).in_(board_ids)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.execute(
|
||||||
|
delete(OrganizationBoardAccess).where(
|
||||||
|
col(OrganizationBoardAccess.organization_member_id).in_(member_ids)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.execute(
|
||||||
|
delete(OrganizationInviteBoardAccess).where(
|
||||||
|
col(OrganizationInviteBoardAccess.organization_invite_id).in_(invite_ids)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.execute(delete(Task).where(col(Task.board_id).in_(board_ids)))
|
||||||
|
await session.execute(delete(Agent).where(col(Agent.board_id).in_(board_ids)))
|
||||||
|
await session.execute(delete(Board).where(col(Board.organization_id) == org_id))
|
||||||
|
await session.execute(
|
||||||
|
delete(BoardGroupMemory).where(col(BoardGroupMemory.board_group_id).in_(group_ids))
|
||||||
|
)
|
||||||
|
await session.execute(delete(BoardGroup).where(col(BoardGroup.organization_id) == org_id))
|
||||||
|
await session.execute(delete(Gateway).where(col(Gateway.organization_id) == org_id))
|
||||||
|
await session.execute(
|
||||||
|
delete(OrganizationInvite).where(col(OrganizationInvite.organization_id) == org_id)
|
||||||
|
)
|
||||||
|
await session.execute(
|
||||||
|
delete(OrganizationMember).where(col(OrganizationMember.organization_id) == org_id)
|
||||||
|
)
|
||||||
|
await session.execute(
|
||||||
|
update(User)
|
||||||
|
.where(col(User.active_organization_id) == org_id)
|
||||||
|
.values(active_organization_id=None)
|
||||||
|
)
|
||||||
|
await session.execute(delete(Organization).where(col(Organization.id) == org_id))
|
||||||
|
await session.commit()
|
||||||
|
return OkResponse()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me/member", response_model=OrganizationMemberRead)
|
@router.get("/me/member", response_model=OrganizationMemberRead)
|
||||||
async def get_my_membership(
|
async def get_my_membership(
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
|
|||||||
77
backend/tests/test_organizations_delete_api.py
Normal file
77
backend/tests/test_organizations_delete_api.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
|
||||||
|
from app.api import organizations
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _FakeSession:
|
||||||
|
executed: list[Any] = field(default_factory=list)
|
||||||
|
committed: int = 0
|
||||||
|
|
||||||
|
async def execute(self, statement: Any) -> None:
|
||||||
|
self.executed.append(statement)
|
||||||
|
|
||||||
|
async def commit(self) -> None:
|
||||||
|
self.committed += 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_my_org_cleans_dependents_before_organization_delete() -> None:
|
||||||
|
session = _FakeSession()
|
||||||
|
org_id = uuid4()
|
||||||
|
ctx = SimpleNamespace(
|
||||||
|
organization=SimpleNamespace(id=org_id),
|
||||||
|
member=SimpleNamespace(role="owner"),
|
||||||
|
)
|
||||||
|
|
||||||
|
await organizations.delete_my_org(session=session, ctx=ctx)
|
||||||
|
|
||||||
|
executed_tables = [statement.table.name for statement in session.executed]
|
||||||
|
assert executed_tables == [
|
||||||
|
"activity_events",
|
||||||
|
"activity_events",
|
||||||
|
"task_dependencies",
|
||||||
|
"task_fingerprints",
|
||||||
|
"approvals",
|
||||||
|
"board_memory",
|
||||||
|
"board_onboarding_sessions",
|
||||||
|
"organization_board_access",
|
||||||
|
"organization_invite_board_access",
|
||||||
|
"organization_board_access",
|
||||||
|
"organization_invite_board_access",
|
||||||
|
"tasks",
|
||||||
|
"agents",
|
||||||
|
"boards",
|
||||||
|
"board_group_memory",
|
||||||
|
"board_groups",
|
||||||
|
"gateways",
|
||||||
|
"organization_invites",
|
||||||
|
"organization_members",
|
||||||
|
"users",
|
||||||
|
"organizations",
|
||||||
|
]
|
||||||
|
assert session.committed == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_my_org_requires_owner_role() -> None:
|
||||||
|
session = _FakeSession()
|
||||||
|
ctx = SimpleNamespace(
|
||||||
|
organization=SimpleNamespace(id=uuid4()),
|
||||||
|
member=SimpleNamespace(role="admin"),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await organizations.delete_my_org(session=session, ctx=ctx)
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == status.HTTP_403_FORBIDDEN
|
||||||
|
assert session.executed == []
|
||||||
|
assert session.committed == 0
|
||||||
@@ -3,12 +3,13 @@
|
|||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
import { SignedIn, SignedOut, useAuth } from "@/auth/clerk";
|
import { SignedIn, SignedOut, useAuth } from "@/auth/clerk";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Building2, Copy, UserPlus, Users } from "lucide-react";
|
import { Building2, Copy, UserPlus, Users } from "lucide-react";
|
||||||
|
|
||||||
import { ApiError } from "@/api/mutator";
|
import { ApiError, customFetch } from "@/api/mutator";
|
||||||
import {
|
import {
|
||||||
type listBoardsApiV1BoardsGetResponse,
|
type listBoardsApiV1BoardsGetResponse,
|
||||||
useListBoardsApiV1BoardsGet,
|
useListBoardsApiV1BoardsGet,
|
||||||
@@ -17,6 +18,7 @@ import {
|
|||||||
type getMyOrgApiV1OrganizationsMeGetResponse,
|
type getMyOrgApiV1OrganizationsMeGetResponse,
|
||||||
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
|
type getMyMembershipApiV1OrganizationsMeMemberGetResponse,
|
||||||
type getOrgMemberApiV1OrganizationsMeMembersMemberIdGetResponse,
|
type getOrgMemberApiV1OrganizationsMeMembersMemberIdGetResponse,
|
||||||
|
getListMyOrganizationsApiV1OrganizationsMeListGetQueryKey,
|
||||||
type listOrgInvitesApiV1OrganizationsMeInvitesGetResponse,
|
type listOrgInvitesApiV1OrganizationsMeInvitesGetResponse,
|
||||||
type listOrgMembersApiV1OrganizationsMeMembersGetResponse,
|
type listOrgMembersApiV1OrganizationsMeMembersGetResponse,
|
||||||
getGetOrgMemberApiV1OrganizationsMeMembersMemberIdGetQueryKey,
|
getGetOrgMemberApiV1OrganizationsMeMembersMemberIdGetQueryKey,
|
||||||
@@ -42,6 +44,7 @@ import { SignedOutPanel } from "@/components/auth/SignedOutPanel";
|
|||||||
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -303,6 +306,7 @@ function BoardAccessEditor({
|
|||||||
|
|
||||||
export default function OrganizationPage() {
|
export default function OrganizationPage() {
|
||||||
const { isSignedIn } = useAuth();
|
const { isSignedIn } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const [inviteDialogOpen, setInviteDialogOpen] = useState(false);
|
const [inviteDialogOpen, setInviteDialogOpen] = useState(false);
|
||||||
@@ -324,6 +328,7 @@ export default function OrganizationPage() {
|
|||||||
const [accessRole, setAccessRole] = useState<string | null>(null);
|
const [accessRole, setAccessRole] = useState<string | null>(null);
|
||||||
const [accessMap, setAccessMap] = useState<BoardAccessState | null>(null);
|
const [accessMap, setAccessMap] = useState<BoardAccessState | null>(null);
|
||||||
const [accessError, setAccessError] = useState<string | null>(null);
|
const [accessError, setAccessError] = useState<string | null>(null);
|
||||||
|
const [deleteOrgOpen, setDeleteOrgOpen] = useState(false);
|
||||||
|
|
||||||
const orgQuery = useGetMyOrgApiV1OrganizationsMeGet<
|
const orgQuery = useGetMyOrgApiV1OrganizationsMeGet<
|
||||||
getMyOrgApiV1OrganizationsMeGetResponse,
|
getMyOrgApiV1OrganizationsMeGetResponse,
|
||||||
@@ -371,10 +376,10 @@ export default function OrganizationPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const isAdmin =
|
const membershipRole =
|
||||||
membershipQuery.data?.status === 200 &&
|
membershipQuery.data?.status === 200 ? membershipQuery.data.data.role : null;
|
||||||
(membershipQuery.data.data.role === "admin" ||
|
const isOwner = membershipRole === "owner";
|
||||||
membershipQuery.data.data.role === "owner");
|
const isAdmin = membershipRole === "admin" || membershipRole === "owner";
|
||||||
|
|
||||||
const invitesQuery = useListOrgInvitesApiV1OrganizationsMeInvitesGet<
|
const invitesQuery = useListOrgInvitesApiV1OrganizationsMeInvitesGet<
|
||||||
listOrgInvitesApiV1OrganizationsMeInvitesGetResponse,
|
listOrgInvitesApiV1OrganizationsMeInvitesGetResponse,
|
||||||
@@ -533,6 +538,25 @@ export default function OrganizationPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const deleteOrganizationMutation = useMutation<
|
||||||
|
{ data: unknown; status: number; headers: Headers },
|
||||||
|
ApiError
|
||||||
|
>({
|
||||||
|
mutationFn: async () =>
|
||||||
|
customFetch<{ data: unknown; status: number; headers: Headers }>(
|
||||||
|
"/api/v1/organizations/me",
|
||||||
|
{ method: "DELETE" },
|
||||||
|
),
|
||||||
|
onSuccess: async () => {
|
||||||
|
setDeleteOrgOpen(false);
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: getListMyOrganizationsApiV1OrganizationsMeListGetQueryKey(),
|
||||||
|
});
|
||||||
|
router.push("/dashboard");
|
||||||
|
router.refresh();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const resetAccessState = () => {
|
const resetAccessState = () => {
|
||||||
setAccessRole(null);
|
setAccessRole(null);
|
||||||
setAccessScope(null);
|
setAccessScope(null);
|
||||||
@@ -691,6 +715,11 @@ export default function OrganizationPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeleteOrganization = () => {
|
||||||
|
if (!isOwner) return;
|
||||||
|
deleteOrganizationMutation.mutate();
|
||||||
|
};
|
||||||
|
|
||||||
const memberAccessSummary = (member: OrganizationMemberRead) =>
|
const memberAccessSummary = (member: OrganizationMemberRead) =>
|
||||||
summarizeAccess(member.all_boards_read, member.all_boards_write);
|
summarizeAccess(member.all_boards_read, member.all_boards_write);
|
||||||
|
|
||||||
@@ -760,6 +789,20 @@ export default function OrganizationPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{isOwner ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="border-rose-200 text-rose-600 hover:border-rose-300 hover:text-rose-700"
|
||||||
|
onClick={() => {
|
||||||
|
deleteOrganizationMutation.reset();
|
||||||
|
setDeleteOrgOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete organization
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setInviteDialogOpen(true)}
|
onClick={() => setInviteDialogOpen(true)}
|
||||||
@@ -774,6 +817,7 @@ export default function OrganizationPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="px-8 py-8">
|
<div className="px-8 py-8">
|
||||||
<div className="overflow-hidden rounded-2xl border border-slate-200 bg-white">
|
<div className="overflow-hidden rounded-2xl border border-slate-200 bg-white">
|
||||||
@@ -1150,6 +1194,29 @@ export default function OrganizationPage() {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
<ConfirmActionDialog
|
||||||
|
open={deleteOrgOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setDeleteOrgOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
deleteOrganizationMutation.reset();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
ariaLabel="Delete organization"
|
||||||
|
title="Delete organization"
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
This will permanently delete <strong>{orgName}</strong>, including
|
||||||
|
boards, groups, gateways, members, and invites. This action cannot
|
||||||
|
be undone.
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
errorMessage={deleteOrganizationMutation.error?.message}
|
||||||
|
onConfirm={handleDeleteOrganization}
|
||||||
|
isConfirming={deleteOrganizationMutation.isPending}
|
||||||
|
confirmLabel="Delete organization"
|
||||||
|
confirmingLabel="Deleting…"
|
||||||
|
/>
|
||||||
</DashboardShell>
|
</DashboardShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user