From b9d2603fde77e2d1cd9502fc3ec9ff0a577a1508 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Mon, 9 Feb 2026 00:22:37 +0530 Subject: [PATCH] feat: implement organization deletion with cascading cleanup of associated resources --- backend/app/api/organizations.py | 90 +++++++++++++++- .../tests/test_organizations_delete_api.py | 77 +++++++++++++ frontend/src/app/organization/page.tsx | 101 +++++++++++++++--- 3 files changed, 250 insertions(+), 18 deletions(-) create mode 100644 backend/tests/test_organizations_delete_api.py diff --git a/backend/app/api/organizations.py b/backend/app/api/organizations.py index d468f58..b03dc89 100644 --- a/backend/app/api/organizations.py +++ b/backend/app/api/organizations.py @@ -5,7 +5,7 @@ from typing import Any, Sequence from uuid import UUID 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.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.db.pagination import paginate 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.gateways import Gateway from app.models.organization_board_access import OrganizationBoardAccess from app.models.organization_invite_board_access import OrganizationInviteBoardAccess from app.models.organization_invites import OrganizationInvite from app.models.organization_members import OrganizationMember 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.schemas.common import OkResponse from app.schemas.organizations import ( OrganizationActiveUpdate, 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) +@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) async def get_my_membership( session: AsyncSession = Depends(get_session), diff --git a/backend/tests/test_organizations_delete_api.py b/backend/tests/test_organizations_delete_api.py new file mode 100644 index 0000000..03b96d2 --- /dev/null +++ b/backend/tests/test_organizations_delete_api.py @@ -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 diff --git a/frontend/src/app/organization/page.tsx b/frontend/src/app/organization/page.tsx index fef746a..e427457 100644 --- a/frontend/src/app/organization/page.tsx +++ b/frontend/src/app/organization/page.tsx @@ -3,12 +3,13 @@ export const dynamic = "force-dynamic"; import { useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; 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 { ApiError } from "@/api/mutator"; +import { ApiError, customFetch } from "@/api/mutator"; import { type listBoardsApiV1BoardsGetResponse, useListBoardsApiV1BoardsGet, @@ -17,6 +18,7 @@ import { type getMyOrgApiV1OrganizationsMeGetResponse, type getMyMembershipApiV1OrganizationsMeMemberGetResponse, type getOrgMemberApiV1OrganizationsMeMembersMemberIdGetResponse, + getListMyOrganizationsApiV1OrganizationsMeListGetQueryKey, type listOrgInvitesApiV1OrganizationsMeInvitesGetResponse, type listOrgMembersApiV1OrganizationsMeMembersGetResponse, getGetOrgMemberApiV1OrganizationsMeMembersMemberIdGetQueryKey, @@ -42,6 +44,7 @@ import { SignedOutPanel } from "@/components/auth/SignedOutPanel"; import { DashboardSidebar } from "@/components/organisms/DashboardSidebar"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog"; import { Dialog, DialogContent, @@ -303,6 +306,7 @@ function BoardAccessEditor({ export default function OrganizationPage() { const { isSignedIn } = useAuth(); + const router = useRouter(); const queryClient = useQueryClient(); const [inviteDialogOpen, setInviteDialogOpen] = useState(false); @@ -324,6 +328,7 @@ export default function OrganizationPage() { const [accessRole, setAccessRole] = useState(null); const [accessMap, setAccessMap] = useState(null); const [accessError, setAccessError] = useState(null); + const [deleteOrgOpen, setDeleteOrgOpen] = useState(false); const orgQuery = useGetMyOrgApiV1OrganizationsMeGet< getMyOrgApiV1OrganizationsMeGetResponse, @@ -371,10 +376,10 @@ export default function OrganizationPage() { }, }); - const isAdmin = - membershipQuery.data?.status === 200 && - (membershipQuery.data.data.role === "admin" || - membershipQuery.data.data.role === "owner"); + const membershipRole = + membershipQuery.data?.status === 200 ? membershipQuery.data.data.role : null; + const isOwner = membershipRole === "owner"; + const isAdmin = membershipRole === "admin" || membershipRole === "owner"; const invitesQuery = useListOrgInvitesApiV1OrganizationsMeInvitesGet< 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 = () => { setAccessRole(null); setAccessScope(null); @@ -691,6 +715,11 @@ export default function OrganizationPage() { } }; + const handleDeleteOrganization = () => { + if (!isOwner) return; + deleteOrganizationMutation.mutate(); + }; + const memberAccessSummary = (member: OrganizationMemberRead) => summarizeAccess(member.all_boards_read, member.all_boards_write); @@ -760,17 +789,32 @@ export default function OrganizationPage() { - +
+ {isOwner ? ( + + ) : null} + +
@@ -1150,6 +1194,29 @@ export default function OrganizationPage() { + { + setDeleteOrgOpen(open); + if (!open) { + deleteOrganizationMutation.reset(); + } + }} + ariaLabel="Delete organization" + title="Delete organization" + description={ + <> + This will permanently delete {orgName}, 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…" + /> ); }