feat: add admin org deletion (#2795)

This commit is contained in:
David Nguyen
2026-05-13 15:28:27 +10:00
committed by GitHub
parent 9a45b3564f
commit cfaad6efc9
17 changed files with 1505 additions and 5 deletions
@@ -0,0 +1,54 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { jobs } from '@documenso/lib/jobs/client';
import { prisma } from '@documenso/prisma';
import { adminProcedure } from '../trpc';
import { ZDeleteOrganisationRequestSchema, ZDeleteOrganisationResponseSchema } from './delete-organisation.types';
export const deleteOrganisationRoute = adminProcedure
.input(ZDeleteOrganisationRequestSchema)
.output(ZDeleteOrganisationResponseSchema)
.mutation(async ({ input, ctx }) => {
const { organisationId, organisationName, sendEmailToOwner } = input;
const { user } = ctx;
ctx.logger.info({
input: {
organisationId,
sendEmailToOwner,
},
});
const organisation = await prisma.organisation.findUnique({
where: {
id: organisationId,
},
select: {
id: true,
name: true,
},
});
if (!organisation) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Organisation not found',
});
}
if (organisation.name !== organisationName) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Organisation name does not match',
});
}
// The deletion itself is offloaded to a background job because orphaning
// potentially-large numbers of envelopes can take a while.
await jobs.triggerJob({
name: 'internal.admin-delete-organisation',
payload: {
organisationId: organisation.id,
sendEmailToOwner,
requestedByUserId: user.id,
},
});
});
@@ -0,0 +1,20 @@
import { z } from 'zod';
export const ZDeleteOrganisationRequestSchema = z.object({
organisationId: z.string().min(1),
/**
* The organisation name as typed by the admin in the confirmation dialog.
* Must exactly match the persisted organisation's name for the deletion
* to proceed.
*/
organisationName: z.string().min(1),
/**
* Whether to email the organisation owner notifying them of the deletion.
*/
sendEmailToOwner: z.boolean(),
});
export const ZDeleteOrganisationResponseSchema = z.void();
export type TDeleteOrganisationRequest = z.infer<typeof ZDeleteOrganisationRequestSchema>;
export type TDeleteOrganisationResponse = z.infer<typeof ZDeleteOrganisationResponseSchema>;
@@ -3,6 +3,7 @@ import { createAdminOrganisationRoute } from './create-admin-organisation';
import { createStripeCustomerRoute } from './create-stripe-customer';
import { createSubscriptionClaimRoute } from './create-subscription-claim';
import { deleteDocumentRoute } from './delete-document';
import { deleteOrganisationRoute } from './delete-organisation';
import { deleteAdminOrganisationMemberRoute } from './delete-organisation-member';
import { deleteSubscriptionClaimRoute } from './delete-subscription-claim';
import { deleteAdminTeamMemberRoute } from './delete-team-member';
@@ -41,6 +42,7 @@ export const adminRouter = router({
get: getAdminOrganisationRoute,
create: createAdminOrganisationRoute,
update: updateAdminOrganisationRoute,
delete: deleteOrganisationRoute,
swapSubscription: swapOrganisationSubscriptionRoute,
},
organisationMember: {
@@ -3,6 +3,7 @@ import {
ORGANISATION_USER_ACCOUNT_TYPE,
} from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { jobs } from '@documenso/lib/jobs/client';
import { orphanEnvelopes } from '@documenso/lib/server-only/envelope/orphan-envelopes';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
@@ -42,6 +43,11 @@ export const deleteOrganisationRoute = authenticatedProcedure
id: true,
},
},
subscription: {
select: {
planId: true,
},
},
},
});
@@ -68,4 +74,18 @@ export const deleteOrganisationRoute = authenticatedProcedure
},
});
});
// If the organisation has a Stripe subscription, schedule it to be
// cancelled at the end of the current billing period. The job runs
// asynchronously so a Stripe outage doesn't block deletion, and is
// retried by the job runner if Stripe is temporarily unavailable.
if (organisation.subscription) {
await jobs.triggerJob({
name: 'internal.cancel-organisation-subscription',
payload: {
stripeSubscriptionId: organisation.subscription.planId,
organisationId: organisation.id,
},
});
}
});