Add organization deletion

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2025-08-07 17:08:04 +02:00
parent c9ab0678be
commit ab97fc8cf8
16 changed files with 958 additions and 232 deletions

View File

@@ -0,0 +1,89 @@
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogFooter,
Button,
Field,
IconTrashCan,
useDialogRef,
} from "@probo/ui";
import { useTranslate } from "@probo/i18n";
import { sprintf } from "@probo/helpers";
type DeleteOrganizationDialogProps = {
children: React.ReactNode;
organizationName: string;
onConfirm: () => void;
isDeleting?: boolean;
};
export function DeleteOrganizationDialog({
children,
organizationName,
onConfirm,
isDeleting = false,
}: DeleteOrganizationDialogProps) {
const { __ } = useTranslate();
const [inputValue, setInputValue] = useState("");
const dialogRef = useDialogRef();
const isConfirmDisabled = inputValue !== organizationName || isDeleting;
const handleConfirm = () => {
if (inputValue === organizationName) {
onConfirm();
}
};
useEffect(() => {
if (!isDeleting) {
setInputValue("");
}
}, [isDeleting]);
return (
<Dialog
className="max-w-lg"
ref={dialogRef}
trigger={children}
title={__("Delete Organization")}
>
<DialogContent padded className="space-y-4">
<p className="text-txt-secondary text-sm">
{sprintf(
__("This will permanently delete the organization %s and all its data."),
organizationName
)}
</p>
<p className="text-red-600 text-sm font-medium">
{__("This action cannot be undone.")}
</p>
<Field
label={sprintf(
__('To confirm deletion, type "%s" below:'),
organizationName
)}
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder={organizationName}
disabled={isDeleting}
autoComplete="off"
autoFocus
/>
</DialogContent>
<DialogFooter>
<Button
variant="danger"
icon={IconTrashCan}
onClick={handleConfirm}
disabled={isConfirmDisabled}
>
{isDeleting ? __("Deleting...") : __("Delete Organization")}
</Button>
</DialogFooter>
</Dialog>
);
}

View File

@@ -1,4 +1,7 @@
import { graphql } from "relay-runtime";
import { useTranslate } from "@probo/i18n";
import { useMutationWithToasts } from "../useMutationWithToasts";
import type { OrganizationGraphDeleteMutation } from "./__generated__/OrganizationGraphDeleteMutation.graphql";
export const organizationViewQuery = graphql`
query OrganizationGraph_ViewQuery($organizationId: ID!) {
@@ -11,3 +14,26 @@ export const organizationViewQuery = graphql`
}
}
`;
const deleteOrganizationMutation = graphql`
mutation OrganizationGraphDeleteMutation(
$input: DeleteOrganizationInput!
$connections: [ID!]!
) {
deleteOrganization(input: $input) {
deletedOrganizationId @deleteEdge(connections: $connections)
}
}
`;
export function useDeleteOrganizationMutation() {
const { __ } = useTranslate();
return useMutationWithToasts<OrganizationGraphDeleteMutation>(
deleteOrganizationMutation,
{
successMessage: __("Organization deleted successfully."),
errorMessage: __("Failed to delete organization. Please try again."),
}
);
}

View File

@@ -0,0 +1,132 @@
/**
* @generated SignedSource<<4c0f8b7da3434de8036a0a24cf89a7ba>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type DeleteOrganizationInput = {
organizationId: string;
};
export type OrganizationGraphDeleteMutation$variables = {
connections: ReadonlyArray<string>;
input: DeleteOrganizationInput;
};
export type OrganizationGraphDeleteMutation$data = {
readonly deleteOrganization: {
readonly deletedOrganizationId: string;
};
};
export type OrganizationGraphDeleteMutation = {
response: OrganizationGraphDeleteMutation$data;
variables: OrganizationGraphDeleteMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "connections"
},
v1 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
},
v2 = [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
v3 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "deletedOrganizationId",
"storageKey": null
};
return {
"fragment": {
"argumentDefinitions": [
(v0/*: any*/),
(v1/*: any*/)
],
"kind": "Fragment",
"metadata": null,
"name": "OrganizationGraphDeleteMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "DeleteOrganizationPayload",
"kind": "LinkedField",
"name": "deleteOrganization",
"plural": false,
"selections": [
(v3/*: any*/)
],
"storageKey": null
}
],
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": [
(v1/*: any*/),
(v0/*: any*/)
],
"kind": "Operation",
"name": "OrganizationGraphDeleteMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "DeleteOrganizationPayload",
"kind": "LinkedField",
"name": "deleteOrganization",
"plural": false,
"selections": [
(v3/*: any*/),
{
"alias": null,
"args": null,
"filters": null,
"handle": "deleteEdge",
"key": "",
"kind": "ScalarHandle",
"name": "deletedOrganizationId",
"handleArgs": [
{
"kind": "Variable",
"name": "connections",
"variableName": "connections"
}
]
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "0f8180f1b546e4e232443869b04cfd36",
"id": null,
"metadata": {},
"name": "OrganizationGraphDeleteMutation",
"operationKind": "mutation",
"text": "mutation OrganizationGraphDeleteMutation(\n $input: DeleteOrganizationInput!\n) {\n deleteOrganization(input: $input) {\n deletedOrganizationId\n }\n}\n"
}
};
})();
(node as any).hash = "9cd2bd6602e261d2728652849d134c71";
export default node;

View File

@@ -4,13 +4,24 @@ import { graphql } from "relay-runtime";
import type { OrganizationsPageQuery as OrganizationsPageQueryType } from "./__generated__/OrganizationsPageQuery.graphql";
import { useEffect } from "react";
import { Link, useNavigate } from "react-router";
import { Avatar, Button, Card, IconPlusLarge } from "@probo/ui";
import {
Avatar,
Button,
Card,
IconPlusLarge,
ActionDropdown,
DropdownItem,
IconTrashCan,
} from "@probo/ui";
import { usePageTitle } from "@probo/hooks";
import { useDeleteOrganizationMutation } from "../hooks/graph/OrganizationGraph";
import { DeleteOrganizationDialog } from "../components/organizations/DeleteOrganizationDialog";
const OrganizationsPageQuery = graphql`
query OrganizationsPageQuery {
viewer {
organizations(first: 25) {
organizations(first: 25) @connection(key: "OrganizationsPage_organizations") {
__id
edges {
node {
id
@@ -37,10 +48,11 @@ export default function OrganizationsPage() {
usePageTitle(__("Select an organization"));
// Redirect to the first organization if only one exists
useEffect(() => {
if (organizations.length === 1) {
navigate(`/organizations/${organizations[0].id}`);
} else if (organizations.length === 0) {
navigate("/organizations/new");
}
}, [organizations]);
@@ -52,27 +64,11 @@ export default function OrganizationsPage() {
</h1>
<div className="space-y-4 w-full">
{organizations.map((organization) => (
<Card
asChild
padded
<OrganizationCard
key={organization.id}
className="w-full hover:bg-tertiary-hover cursor-pointer"
>
<Link
to={`/organizations/${organization.id}`}
className="flex items-center justify-between"
>
<div className="flex items-center gap-4">
<Avatar
src={organization.logoUrl}
name={organization.name}
size="l"
/>
<h2 className="font-semibold text-xl">{organization.name}</h2>
</div>
<Button>{__("Select")}</Button>
</Link>
</Card>
organization={organization}
connectionId={data.viewer.organizations.__id}
/>
))}
<Card padded>
<h2 className="text-xl font-semibold mb-1">
@@ -95,3 +91,68 @@ export default function OrganizationsPage() {
</>
);
}
type OrganizationCardProps = {
organization: {
id: string;
name: string;
logoUrl: string | null | undefined;
};
connectionId: string;
};
function OrganizationCard({ organization, connectionId }: OrganizationCardProps) {
const { __ } = useTranslate();
const [deleteOrganization, isDeleting] = useDeleteOrganizationMutation();
const handleDelete = () => {
return deleteOrganization({
variables: {
input: {
organizationId: organization.id,
},
connections: [connectionId],
},
});
};
return (
<Card padded className="w-full">
<div className="flex items-center justify-between">
<Link
to={`/organizations/${organization.id}`}
className="flex items-center gap-4 hover:text-primary flex-1"
>
<Avatar
src={organization.logoUrl}
name={organization.name}
size="l"
/>
<h2 className="font-semibold text-xl">{organization.name}</h2>
</Link>
<div className="flex items-center gap-3">
<Button asChild>
<Link to={`/organizations/${organization.id}`}>
{__("Select")}
</Link>
</Button>
<DeleteOrganizationDialog
organizationName={organization.name}
onConfirm={handleDelete}
isDeleting={isDeleting}
>
<ActionDropdown>
<DropdownItem
variant="danger"
icon={IconTrashCan}
disabled={isDeleting}
>
{__("Delete organization")}
</DropdownItem>
</ActionDropdown>
</DeleteOrganizationDialog>
</div>
</div>
</Card>
);
}

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<91cc89e5e07572ab16c7bc9b8443fcd6>>
* @generated SignedSource<<70d28036f17c3b03dcfecce6abd5b476>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -13,6 +13,7 @@ export type OrganizationsPageQuery$variables = Record<PropertyKey, never>;
export type OrganizationsPageQuery$data = {
readonly viewer: {
readonly organizations: {
readonly __id: string;
readonly edges: ReadonlyArray<{
readonly node: {
readonly id: string;
@@ -36,60 +37,103 @@ var v0 = {
"name": "id",
"storageKey": null
},
v1 = {
"alias": null,
"args": [
{
"kind": "Literal",
"name": "first",
"value": 25
}
],
"concreteType": "OrganizationConnection",
"kind": "LinkedField",
"name": "organizations",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "OrganizationEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Organization",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v0/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "logoUrl",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": "organizations(first:25)"
};
v1 = [
{
"alias": null,
"args": null,
"concreteType": "OrganizationEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Organization",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v0/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "name",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "logoUrl",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "cursor",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "PageInfo",
"kind": "LinkedField",
"name": "pageInfo",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "endCursor",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasNextPage",
"storageKey": null
}
],
"storageKey": null
},
{
"kind": "ClientExtension",
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__id",
"storageKey": null
}
]
}
],
v2 = [
{
"kind": "Literal",
"name": "first",
"value": 25
}
];
return {
"fragment": {
"argumentDefinitions": [],
@@ -105,7 +149,16 @@ return {
"name": "viewer",
"plural": false,
"selections": [
(v1/*: any*/)
{
"alias": "organizations",
"args": null,
"concreteType": "OrganizationConnection",
"kind": "LinkedField",
"name": "__OrganizationsPage_organizations_connection",
"plural": false,
"selections": (v1/*: any*/),
"storageKey": null
}
],
"storageKey": null
}
@@ -127,7 +180,25 @@ return {
"name": "viewer",
"plural": false,
"selections": [
(v1/*: any*/),
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "OrganizationConnection",
"kind": "LinkedField",
"name": "organizations",
"plural": false,
"selections": (v1/*: any*/),
"storageKey": "organizations(first:25)"
},
{
"alias": null,
"args": (v2/*: any*/),
"filters": null,
"handle": "connection",
"key": "OrganizationsPage_organizations",
"kind": "LinkedHandle",
"name": "organizations"
},
(v0/*: any*/)
],
"storageKey": null
@@ -135,16 +206,28 @@ return {
]
},
"params": {
"cacheID": "e771f8e44e9f9224d9cbe0f271b096ed",
"cacheID": "ddaafef96749a932a0cee5286a3c2bbd",
"id": null,
"metadata": {},
"metadata": {
"connection": [
{
"count": null,
"cursor": null,
"direction": "forward",
"path": [
"viewer",
"organizations"
]
}
]
},
"name": "OrganizationsPageQuery",
"operationKind": "query",
"text": "query OrganizationsPageQuery {\n viewer {\n organizations(first: 25) {\n edges {\n node {\n id\n name\n logoUrl\n }\n }\n }\n id\n }\n}\n"
"text": "query OrganizationsPageQuery {\n viewer {\n organizations(first: 25) {\n edges {\n node {\n id\n name\n logoUrl\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n id\n }\n}\n"
}
};
})();
(node as any).hash = "457b1b8b28355830d77458bab7af23f2";
(node as any).hash = "122ab4331082ffe892fa7fe48692d961";
export default node;

View File

@@ -32,6 +32,9 @@ import clsx from "clsx";
import { useMutationWithToasts } from "/hooks/useMutationWithToasts";
import { useOrganizationId } from "/hooks/useOrganizationId";
import { InviteUserDialog } from "/components/organizations/InviteUserDialog";
import { useDeleteOrganizationMutation } from "/hooks/graph/OrganizationGraph";
import { useNavigate } from "react-router";
import { DeleteOrganizationDialog } from "/components/organizations/DeleteOrganizationDialog";
type Props = {
queryRef: PreloadedQuery<OrganizationGraph_ViewQuery>;
@@ -79,6 +82,7 @@ const updateOrganizationMutation = graphql`
export default function SettingsPage({ queryRef }: Props) {
const { __ } = useTranslate();
const navigate = useNavigate();
const organizationKey = usePreloadedQuery(
organizationViewQuery,
queryRef
@@ -91,6 +95,7 @@ export default function SettingsPage({ queryRef }: Props) {
const [updateOrganization, isUpdating] = useMutation(
updateOrganizationMutation
);
const [deleteOrganization, isDeleting] = useDeleteOrganizationMutation();
const users = organization.users.edges.map((edge) => edge.node);
const updateOrganizationName = useDebounceCallback((name: string) => {
@@ -132,6 +137,20 @@ export default function SettingsPage({ queryRef }: Props) {
});
};
const handleDeleteOrganization = () => {
return deleteOrganization({
variables: {
input: {
organizationId: organization.id,
},
connections: [],
},
onSuccess: () => {
navigate("/", { replace: true });
},
});
};
return (
<div className="space-y-6">
<PageHeader title={__("Settings")} />
@@ -200,6 +219,36 @@ export default function SettingsPage({ queryRef }: Props) {
/>
</Card>
</div>
<div className="space-y-4">
<h2 className="text-base font-medium text-red-600">{__("Danger Zone")}</h2>
<Card padded className="border-red-200 flex items-center gap-3">
<div className="mr-auto">
<h3 className="text-base font-semibold text-red-700">
{__("Delete Organization")}
</h3>
<p className="text-sm text-txt-tertiary">
{__("Permanently delete this organization and all its data.")}{" "}
<span className="text-red-600 font-medium">
{__("This action cannot be undone.")}
</span>
</p>
</div>
<DeleteOrganizationDialog
organizationName={organization.name}
onConfirm={handleDeleteOrganization}
isDeleting={isDeleting}
>
<Button
variant="danger"
icon={IconTrashCan}
disabled={isDeleting}
>
{isDeleting ? __("Deleting...") : __("Delete Organization")}
</Button>
</DeleteOrganizationDialog>
</Card>
</div>
</div>
);
}

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<78eb861601ac1babc8f02dc877d9e1cf>>
* @generated SignedSource<<e02cdda6c1c24ac700a79ed236c6dfdc>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -25,6 +25,7 @@ export type DocumentDetailPageUpdateMutation$variables = {
export type DocumentDetailPageUpdateMutation$data = {
readonly updateDocument: {
readonly document: {
readonly documentType: DocumentType;
readonly id: string;
readonly owner: {
readonly fullName: string;
@@ -85,6 +86,13 @@ v2 = [
"name": "title",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "documentType",
"storageKey": null
},
{
"alias": null,
"args": null,
@@ -129,16 +137,16 @@ return {
"selections": (v2/*: any*/)
},
"params": {
"cacheID": "bb375479bf3446cccb49753a27fb6bcb",
"cacheID": "23268b13b11a1a56164dcc4cb96e24b3",
"id": null,
"metadata": {},
"name": "DocumentDetailPageUpdateMutation",
"operationKind": "mutation",
"text": "mutation DocumentDetailPageUpdateMutation(\n $input: UpdateDocumentInput!\n) {\n updateDocument(input: $input) {\n document {\n id\n title\n owner {\n id\n fullName\n }\n }\n }\n}\n"
"text": "mutation DocumentDetailPageUpdateMutation(\n $input: UpdateDocumentInput!\n) {\n updateDocument(input: $input) {\n document {\n id\n title\n documentType\n owner {\n id\n fullName\n }\n }\n }\n}\n"
}
};
})();
(node as any).hash = "9d204fe6a01448bd05d68699c06d18f9";
(node as any).hash = "507a2bd40b923d2064fdd864f16f29aa";
export default node;

View File

@@ -1,144 +0,0 @@
/**
* @generated SignedSource<<5b79ea2de8e0c11b167b479ef72558c6>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type DocumentType = "ISMS" | "OTHER" | "POLICY";
export type UpdateDocumentInput = {
content?: string | null | undefined;
createdBy?: string | null | undefined;
documentType?: DocumentType | null | undefined;
id: string;
ownerId?: string | null | undefined;
showOnTrustCenter?: boolean | null | undefined;
title?: string | null | undefined;
};
export type UpdateDocumentDialogMutation$variables = {
input: UpdateDocumentInput;
};
export type UpdateDocumentDialogMutation$data = {
readonly updateDocument: {
readonly document: {
readonly id: string;
readonly owner: {
readonly fullName: string;
readonly id: string;
};
readonly title: string;
};
};
};
export type UpdateDocumentDialogMutation = {
response: UpdateDocumentDialogMutation$data;
variables: UpdateDocumentDialogMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
}
],
v1 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
v2 = [
{
"alias": null,
"args": [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
"concreteType": "UpdateDocumentPayload",
"kind": "LinkedField",
"name": "updateDocument",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Document",
"kind": "LinkedField",
"name": "document",
"plural": false,
"selections": [
(v1/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "title",
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "People",
"kind": "LinkedField",
"name": "owner",
"plural": false,
"selections": [
(v1/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fullName",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
];
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "UpdateDocumentDialogMutation",
"selections": (v2/*: any*/),
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "UpdateDocumentDialogMutation",
"selections": (v2/*: any*/)
},
"params": {
"cacheID": "27831214e4f4c0a988553abccf99d547",
"id": null,
"metadata": {},
"name": "UpdateDocumentDialogMutation",
"operationKind": "mutation",
"text": "mutation UpdateDocumentDialogMutation(\n $input: UpdateDocumentInput!\n) {\n updateDocument(input: $input) {\n document {\n id\n title\n owner {\n id\n fullName\n }\n }\n }\n}\n"
}
};
})();
(node as any).hash = "ea3902438d59438b49c5d34edadbe5e4";
export default node;

View File

@@ -250,6 +250,25 @@ DELETE FROM documents WHERE %s AND id = @document_id
return err
}
func (p Document) DeleteByOrganizationID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
organizationID gid.GID,
) error {
q := `
DELETE FROM documents WHERE %s AND organization_id = @organization_id
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"organization_id": organizationID}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, q, args)
return err
}
func (p *Document) Update(
ctx context.Context,
conn pg.Conn,

View File

@@ -0,0 +1,45 @@
ALTER TABLE frameworks DROP CONSTRAINT IF EXISTS frameworks_organization_id_fkey;
ALTER TABLE frameworks ADD CONSTRAINT frameworks_organization_id_fkey
FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
ALTER TABLE peoples DROP CONSTRAINT IF EXISTS peoples_organization_id_fkey;
ALTER TABLE peoples ADD CONSTRAINT peoples_organization_id_fkey
FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
ALTER TABLE vendors DROP CONSTRAINT IF EXISTS vendors_organization_id_fkey;
ALTER TABLE vendors ADD CONSTRAINT vendors_organization_id_fkey
FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
ALTER TABLE documents DROP CONSTRAINT IF EXISTS policies_organization_id_fkey;
ALTER TABLE documents DROP CONSTRAINT IF EXISTS documents_organization_id_fkey;
ALTER TABLE documents ADD CONSTRAINT documents_organization_id_fkey
FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
ALTER TABLE risks DROP CONSTRAINT IF EXISTS risks_organization_id_fkey;
ALTER TABLE risks ADD CONSTRAINT risks_organization_id_fkey
FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
ALTER TABLE connectors DROP CONSTRAINT IF EXISTS connectors_organization_id_fkey;
ALTER TABLE connectors ADD CONSTRAINT connectors_organization_id_fkey
FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
ALTER TABLE data DROP CONSTRAINT IF EXISTS data_organization_id_fkey;
ALTER TABLE data ADD CONSTRAINT data_organization_id_fkey
FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
ALTER TABLE users_organizations ADD CONSTRAINT users_organizations_organization_id_fkey
FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE;
ALTER TABLE controls DROP CONSTRAINT IF EXISTS controls_framework_id_fkey;
ALTER TABLE controls ADD CONSTRAINT controls_framework_id_fkey
FOREIGN KEY (framework_id) REFERENCES frameworks(id) ON DELETE CASCADE;
ALTER TABLE document_versions DROP CONSTRAINT IF EXISTS document_versions_document_id_fkey;
ALTER TABLE document_versions ADD CONSTRAINT document_versions_document_id_fkey
FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE;
ALTER TABLE document_versions DROP CONSTRAINT IF EXISTS document_versions_published_by_fkey;
ALTER TABLE document_versions ADD CONSTRAINT document_versions_published_by_fkey
FOREIGN KEY (published_by) REFERENCES peoples(id)
ON DELETE SET NULL
ON UPDATE CASCADE;

View File

@@ -156,3 +156,28 @@ WHERE
return nil
}
func (o *Organization) Delete(
ctx context.Context,
conn pg.Conn,
scope Scoper,
) error {
q := `
DELETE FROM organizations
WHERE
%s
AND id = @id
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"id": o.ID}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot delete organization: %w", err)
}
return nil
}

View File

@@ -281,6 +281,51 @@ func (s OrganizationService) GenerateLogoURL(
return &presignedReq.URL, nil
}
func (s OrganizationService) Delete(
ctx context.Context,
organizationID gid.GID,
) error {
organization := &coredata.Organization{}
err := s.svc.pg.WithTx(
ctx,
func(tx pg.Conn) error {
if err := organization.LoadByID(ctx, tx, s.svc.scope, organizationID); err != nil {
return fmt.Errorf("cannot load organization: %w", err)
}
// Delete documents first because versions and signatures have a delete restriction on people
// that must be resolved before deleting the organization
document := &coredata.Document{}
if err := document.DeleteByOrganizationID(ctx, tx, s.svc.scope, organizationID); err != nil {
return fmt.Errorf("cannot delete documents: %w", err)
}
if err := organization.Delete(ctx, tx, s.svc.scope); err != nil {
return fmt.Errorf("cannot delete organization: %w", err)
}
return nil
},
)
if err != nil {
return err
}
if organization.LogoObjectKey != "" {
_, err := s.svc.s3.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(s.svc.bucket),
Key: aws.String(organization.LogoObjectKey),
})
if err != nil {
return fmt.Errorf("organization deleted but failed to delete logo from S3: %w", err)
}
}
return nil
}
func (s OrganizationService) createProboVendor(ctx context.Context, tx pg.Conn, organization *coredata.Organization, now time.Time) error {
proboData := &coredata.Vendor{
ID: gid.New(s.svc.scope.GetTenantID(), coredata.VendorEntityType),

View File

@@ -1478,6 +1478,9 @@ type Mutation {
updateOrganization(
input: UpdateOrganizationInput!
): UpdateOrganizationPayload!
deleteOrganization(
input: DeleteOrganizationInput!
): DeleteOrganizationPayload!
updateTrustCenter(
input: UpdateTrustCenterInput!
@@ -1658,6 +1661,10 @@ input UpdateOrganizationInput {
logo: Upload
}
input DeleteOrganizationInput {
organizationId: ID!
}
input UpdateTrustCenterInput {
trustCenterId: ID!
active: Boolean
@@ -2035,6 +2042,10 @@ type UpdateOrganizationPayload {
organization: Organization!
}
type DeleteOrganizationPayload {
deletedOrganizationId: ID!
}
type UpdateTrustCenterPayload {
trustCenter: TrustCenter!
}

View File

@@ -355,6 +355,10 @@ type ComplexityRoot struct {
DeletedMeasureID func(childComplexity int) int
}
DeleteOrganizationPayload struct {
DeletedOrganizationID func(childComplexity int) int
}
DeletePeoplePayload struct {
DeletedPeopleID func(childComplexity int) int
}
@@ -601,6 +605,7 @@ type ComplexityRoot struct {
DeleteEvidence func(childComplexity int, input types.DeleteEvidenceInput) int
DeleteFramework func(childComplexity int, input types.DeleteFrameworkInput) int
DeleteMeasure func(childComplexity int, input types.DeleteMeasureInput) int
DeleteOrganization func(childComplexity int, input types.DeleteOrganizationInput) int
DeletePeople func(childComplexity int, input types.DeletePeopleInput) int
DeleteRisk func(childComplexity int, input types.DeleteRiskInput) int
DeleteRiskDocumentMapping func(childComplexity int, input types.DeleteRiskDocumentMappingInput) int
@@ -1119,6 +1124,7 @@ type MeasureConnectionResolver interface {
type MutationResolver interface {
CreateOrganization(ctx context.Context, input types.CreateOrganizationInput) (*types.CreateOrganizationPayload, error)
UpdateOrganization(ctx context.Context, input types.UpdateOrganizationInput) (*types.UpdateOrganizationPayload, error)
DeleteOrganization(ctx context.Context, input types.DeleteOrganizationInput) (*types.DeleteOrganizationPayload, error)
UpdateTrustCenter(ctx context.Context, input types.UpdateTrustCenterInput) (*types.UpdateTrustCenterPayload, error)
CreateTrustCenterAccess(ctx context.Context, input types.CreateTrustCenterAccessInput) (*types.CreateTrustCenterAccessPayload, error)
DeleteTrustCenterAccess(ctx context.Context, input types.DeleteTrustCenterAccessInput) (*types.DeleteTrustCenterAccessPayload, error)
@@ -2115,6 +2121,13 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
return e.complexity.DeleteMeasurePayload.DeletedMeasureID(childComplexity), true
case "DeleteOrganizationPayload.deletedOrganizationId":
if e.complexity.DeleteOrganizationPayload.DeletedOrganizationID == nil {
break
}
return e.complexity.DeleteOrganizationPayload.DeletedOrganizationID(childComplexity), true
case "DeletePeoplePayload.deletedPeopleId":
if e.complexity.DeletePeoplePayload.DeletedPeopleID == nil {
break
@@ -3357,6 +3370,18 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
return e.complexity.Mutation.DeleteMeasure(childComplexity, args["input"].(types.DeleteMeasureInput)), true
case "Mutation.deleteOrganization":
if e.complexity.Mutation.DeleteOrganization == nil {
break
}
args, err := ec.field_Mutation_deleteOrganization_args(ctx, rawArgs)
if err != nil {
return 0, false
}
return e.complexity.Mutation.DeleteOrganization(childComplexity, args["input"].(types.DeleteOrganizationInput)), true
case "Mutation.deletePeople":
if e.complexity.Mutation.DeletePeople == nil {
break
@@ -5473,6 +5498,7 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler {
ec.unmarshalInputDeleteEvidenceInput,
ec.unmarshalInputDeleteFrameworkInput,
ec.unmarshalInputDeleteMeasureInput,
ec.unmarshalInputDeleteOrganizationInput,
ec.unmarshalInputDeletePeopleInput,
ec.unmarshalInputDeleteRiskDocumentMappingInput,
ec.unmarshalInputDeleteRiskInput,
@@ -7110,6 +7136,9 @@ type Mutation {
updateOrganization(
input: UpdateOrganizationInput!
): UpdateOrganizationPayload!
deleteOrganization(
input: DeleteOrganizationInput!
): DeleteOrganizationPayload!
updateTrustCenter(
input: UpdateTrustCenterInput!
@@ -7290,6 +7319,10 @@ input UpdateOrganizationInput {
logo: Upload
}
input DeleteOrganizationInput {
organizationId: ID!
}
input UpdateTrustCenterInput {
trustCenterId: ID!
active: Boolean
@@ -7667,6 +7700,10 @@ type UpdateOrganizationPayload {
organization: Organization!
}
type DeleteOrganizationPayload {
deletedOrganizationId: ID!
}
type UpdateTrustCenterPayload {
trustCenter: TrustCenter!
}
@@ -10370,6 +10407,29 @@ func (ec *executionContext) field_Mutation_deleteMeasure_argsInput(
return zeroVal, nil
}
func (ec *executionContext) field_Mutation_deleteOrganization_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) {
var err error
args := map[string]any{}
arg0, err := ec.field_Mutation_deleteOrganization_argsInput(ctx, rawArgs)
if err != nil {
return nil, err
}
args["input"] = arg0
return args, nil
}
func (ec *executionContext) field_Mutation_deleteOrganization_argsInput(
ctx context.Context,
rawArgs map[string]any,
) (types.DeleteOrganizationInput, error) {
ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("input"))
if tmp, ok := rawArgs["input"]; ok {
return ec.unmarshalNDeleteOrganizationInput2githubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐDeleteOrganizationInput(ctx, tmp)
}
var zeroVal types.DeleteOrganizationInput
return zeroVal, nil
}
func (ec *executionContext) field_Mutation_deletePeople_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) {
var err error
args := map[string]any{}
@@ -19457,6 +19517,50 @@ func (ec *executionContext) fieldContext_DeleteMeasurePayload_deletedMeasureId(_
return fc, nil
}
func (ec *executionContext) _DeleteOrganizationPayload_deletedOrganizationId(ctx context.Context, field graphql.CollectedField, obj *types.DeleteOrganizationPayload) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_DeleteOrganizationPayload_deletedOrganizationId(ctx, field)
if err != nil {
return graphql.Null
}
ctx = graphql.WithFieldContext(ctx, fc)
defer func() {
if r := recover(); r != nil {
ec.Error(ctx, ec.Recover(ctx, r))
ret = graphql.Null
}
}()
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
ctx = rctx // use context from middleware stack in children
return obj.DeletedOrganizationID, nil
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
if !graphql.HasFieldError(ctx, fc) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null
}
res := resTmp.(gid.GID)
fc.Result = res
return ec.marshalNID2githubᚗcomᚋgetproboᚋproboᚋpkgᚋgidᚐGID(ctx, field.Selections, res)
}
func (ec *executionContext) fieldContext_DeleteOrganizationPayload_deletedOrganizationId(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "DeleteOrganizationPayload",
Field: field,
IsMethod: false,
IsResolver: false,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
return nil, errors.New("field of type ID does not have child fields")
},
}
return fc, nil
}
func (ec *executionContext) _DeletePeoplePayload_deletedPeopleId(ctx context.Context, field graphql.CollectedField, obj *types.DeletePeoplePayload) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_DeletePeoplePayload_deletedPeopleId(ctx, field)
if err != nil {
@@ -25092,6 +25196,65 @@ func (ec *executionContext) fieldContext_Mutation_updateOrganization(ctx context
return fc, nil
}
func (ec *executionContext) _Mutation_deleteOrganization(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_Mutation_deleteOrganization(ctx, field)
if err != nil {
return graphql.Null
}
ctx = graphql.WithFieldContext(ctx, fc)
defer func() {
if r := recover(); r != nil {
ec.Error(ctx, ec.Recover(ctx, r))
ret = graphql.Null
}
}()
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
ctx = rctx // use context from middleware stack in children
return ec.resolvers.Mutation().DeleteOrganization(rctx, fc.Args["input"].(types.DeleteOrganizationInput))
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
if !graphql.HasFieldError(ctx, fc) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null
}
res := resTmp.(*types.DeleteOrganizationPayload)
fc.Result = res
return ec.marshalNDeleteOrganizationPayload2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐDeleteOrganizationPayload(ctx, field.Selections, res)
}
func (ec *executionContext) fieldContext_Mutation_deleteOrganization(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "Mutation",
Field: field,
IsMethod: true,
IsResolver: true,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
switch field.Name {
case "deletedOrganizationId":
return ec.fieldContext_DeleteOrganizationPayload_deletedOrganizationId(ctx, field)
}
return nil, fmt.Errorf("no field named %q was found under type DeleteOrganizationPayload", field.Name)
},
}
defer func() {
if r := recover(); r != nil {
err = ec.Recover(ctx, r)
ec.Error(ctx, err)
}
}()
ctx = graphql.WithFieldContext(ctx, fc)
if fc.Args, err = ec.field_Mutation_deleteOrganization_args(ctx, field.ArgumentMap(ec.Variables)); err != nil {
ec.Error(ctx, err)
return fc, err
}
return fc, nil
}
func (ec *executionContext) _Mutation_updateTrustCenter(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_Mutation_updateTrustCenter(ctx, field)
if err != nil {
@@ -44360,6 +44523,33 @@ func (ec *executionContext) unmarshalInputDeleteMeasureInput(ctx context.Context
return it, nil
}
func (ec *executionContext) unmarshalInputDeleteOrganizationInput(ctx context.Context, obj any) (types.DeleteOrganizationInput, error) {
var it types.DeleteOrganizationInput
asMap := map[string]any{}
for k, v := range obj.(map[string]any) {
asMap[k] = v
}
fieldsInOrder := [...]string{"organizationId"}
for _, k := range fieldsInOrder {
v, ok := asMap[k]
if !ok {
continue
}
switch k {
case "organizationId":
ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("organizationId"))
data, err := ec.unmarshalNID2githubᚗcomᚋgetproboᚋproboᚋpkgᚋgidᚐGID(ctx, v)
if err != nil {
return it, err
}
it.OrganizationID = data
}
}
return it, nil
}
func (ec *executionContext) unmarshalInputDeletePeopleInput(ctx context.Context, obj any) (types.DeletePeopleInput, error) {
var it types.DeletePeopleInput
asMap := map[string]any{}
@@ -49842,6 +50032,45 @@ func (ec *executionContext) _DeleteMeasurePayload(ctx context.Context, sel ast.S
return out
}
var deleteOrganizationPayloadImplementors = []string{"DeleteOrganizationPayload"}
func (ec *executionContext) _DeleteOrganizationPayload(ctx context.Context, sel ast.SelectionSet, obj *types.DeleteOrganizationPayload) graphql.Marshaler {
fields := graphql.CollectFields(ec.OperationContext, sel, deleteOrganizationPayloadImplementors)
out := graphql.NewFieldSet(fields)
deferred := make(map[string]*graphql.FieldSet)
for i, field := range fields {
switch field.Name {
case "__typename":
out.Values[i] = graphql.MarshalString("DeleteOrganizationPayload")
case "deletedOrganizationId":
out.Values[i] = ec._DeleteOrganizationPayload_deletedOrganizationId(ctx, field, obj)
if out.Values[i] == graphql.Null {
out.Invalids++
}
default:
panic("unknown field " + strconv.Quote(field.Name))
}
}
out.Dispatch(ctx)
if out.Invalids > 0 {
return graphql.Null
}
atomic.AddInt32(&ec.deferred, int32(len(deferred)))
for label, dfs := range deferred {
ec.processDeferredGroup(graphql.DeferredGroup{
Label: label,
Path: graphql.GetPath(ctx),
FieldSet: dfs,
Context: ctx,
})
}
return out
}
var deletePeoplePayloadImplementors = []string{"DeletePeoplePayload"}
func (ec *executionContext) _DeletePeoplePayload(ctx context.Context, sel ast.SelectionSet, obj *types.DeletePeoplePayload) graphql.Marshaler {
@@ -52270,6 +52499,13 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet)
if out.Values[i] == graphql.Null {
out.Invalids++
}
case "deleteOrganization":
out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) {
return ec._Mutation_deleteOrganization(ctx, field)
})
if out.Values[i] == graphql.Null {
out.Invalids++
}
case "updateTrustCenter":
out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) {
return ec._Mutation_updateTrustCenter(ctx, field)
@@ -58964,6 +59200,25 @@ func (ec *executionContext) marshalNDeleteMeasurePayload2ᚖgithubᚗcomᚋgetpr
return ec._DeleteMeasurePayload(ctx, sel, v)
}
func (ec *executionContext) unmarshalNDeleteOrganizationInput2githubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐDeleteOrganizationInput(ctx context.Context, v any) (types.DeleteOrganizationInput, error) {
res, err := ec.unmarshalInputDeleteOrganizationInput(ctx, v)
return res, graphql.ErrorOnPath(ctx, err)
}
func (ec *executionContext) marshalNDeleteOrganizationPayload2githubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐDeleteOrganizationPayload(ctx context.Context, sel ast.SelectionSet, v types.DeleteOrganizationPayload) graphql.Marshaler {
return ec._DeleteOrganizationPayload(ctx, sel, &v)
}
func (ec *executionContext) marshalNDeleteOrganizationPayload2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐDeleteOrganizationPayload(ctx context.Context, sel ast.SelectionSet, v *types.DeleteOrganizationPayload) graphql.Marshaler {
if v == nil {
if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {
ec.Errorf(ctx, "the requested element is null which the schema does not allow")
}
return graphql.Null
}
return ec._DeleteOrganizationPayload(ctx, sel, v)
}
func (ec *executionContext) unmarshalNDeletePeopleInput2githubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐDeletePeopleInput(ctx context.Context, v any) (types.DeletePeopleInput, error) {
res, err := ec.unmarshalInputDeletePeopleInput(ctx, v)
return res, graphql.ErrorOnPath(ctx, err)

View File

@@ -527,6 +527,14 @@ type DeleteMeasurePayload struct {
DeletedMeasureID gid.GID `json:"deletedMeasureId"`
}
type DeleteOrganizationInput struct {
OrganizationID gid.GID `json:"organizationId"`
}
type DeleteOrganizationPayload struct {
DeletedOrganizationID gid.GID `json:"deletedOrganizationId"`
}
type DeletePeopleInput struct {
PeopleID gid.GID `json:"peopleId"`
}

View File

@@ -980,6 +980,20 @@ func (r *mutationResolver) UpdateOrganization(ctx context.Context, input types.U
}, nil
}
// DeleteOrganization is the resolver for the deleteOrganization field.
func (r *mutationResolver) DeleteOrganization(ctx context.Context, input types.DeleteOrganizationInput) (*types.DeleteOrganizationPayload, error) {
prb := r.ProboService(ctx, input.OrganizationID.TenantID())
err := prb.Organizations.Delete(ctx, input.OrganizationID)
if err != nil {
return nil, fmt.Errorf("cannot delete organization: %w", err)
}
return &types.DeleteOrganizationPayload{
DeletedOrganizationID: input.OrganizationID,
}, nil
}
// UpdateTrustCenter is the resolver for the updateTrustCenter field.
func (r *mutationResolver) UpdateTrustCenter(ctx context.Context, input types.UpdateTrustCenterInput) (*types.UpdateTrustCenterPayload, error) {
prb := r.ProboService(ctx, input.TrustCenterID.TenantID())