feat: implement organization deletion with cascading cleanup of associated resources

This commit is contained in:
Abhimanyu Saharan
2026-02-09 00:22:37 +05:30
parent fd01320f1b
commit b9d2603fde
3 changed files with 250 additions and 18 deletions

View File

@@ -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),

View 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

View File

@@ -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>
); );
} }