feat: unlink documents from deleted organization (#2006)

This commit is contained in:
Catalin Pit
2025-12-15 03:17:13 +02:00
committed by GitHub
parent 928edb8645
commit 02f1264eea
8 changed files with 245 additions and 21 deletions
@@ -0,0 +1,52 @@
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { deletedAccountServiceAccount } from '../user/service-accounts/deleted-account';
export type OrphanEnvelopesOptions = {
teamId: number;
};
export const orphanEnvelopes = async ({ teamId }: OrphanEnvelopesOptions) => {
const serviceAccount = await deletedAccountServiceAccount();
// Transfer all inflight and completed envelopes to the service account.
await prisma.envelope.updateMany({
where: {
teamId,
type: EnvelopeType.DOCUMENT,
status: {
in: [DocumentStatus.PENDING, DocumentStatus.REJECTED, DocumentStatus.COMPLETED],
},
deletedAt: null,
},
data: {
userId: serviceAccount.id,
teamId: serviceAccount.ownedOrganisations[0].teams[0].id,
deletedAt: new Date(),
},
});
// Transfer any remaining deleted envelopes to the service account.
await prisma.envelope.updateMany({
where: {
teamId,
type: EnvelopeType.DOCUMENT,
status: {
in: [DocumentStatus.PENDING, DocumentStatus.REJECTED, DocumentStatus.COMPLETED],
},
},
data: {
userId: serviceAccount.id,
teamId: serviceAccount.ownedOrganisations[0].teams[0].id,
},
});
// Then delete anything remaining across documents and templates.
await prisma.envelope.deleteMany({
where: {
teamId,
},
});
};
@@ -0,0 +1,20 @@
import { prisma } from '@documenso/prisma';
export type TransferTeamEnvelopesOptions = {
sourceTeamId: number;
targetTeamId: number;
};
export const transferTeamEnvelopes = async ({
sourceTeamId,
targetTeamId,
}: TransferTeamEnvelopesOptions) => {
await prisma.envelope.updateMany({
where: {
teamId: sourceTeamId,
},
data: {
teamId: targetTeamId,
},
});
};
+54 -18
View File
@@ -1,9 +1,7 @@
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { deletedAccountServiceAccount } from './service-accounts/deleted-account';
import { orphanEnvelopes } from '../envelope/orphan-envelopes';
export type DeleteUserOptions = {
id: number;
@@ -14,6 +12,30 @@ export const deleteUser = async ({ id }: DeleteUserOptions) => {
where: {
id,
},
include: {
ownedOrganisations: {
include: {
teams: {
select: {
id: true,
},
},
},
},
organisationMember: {
include: {
organisation: {
include: {
teams: {
select: {
id: true,
},
},
},
},
},
},
},
});
if (!user) {
@@ -22,22 +44,36 @@ export const deleteUser = async ({ id }: DeleteUserOptions) => {
});
}
const serviceAccount = await deletedAccountServiceAccount();
// Get team IDs from organisations the user owns.
const ownedTeamIds = user.ownedOrganisations.flatMap((org) => org.teams.map((team) => team.id));
// TODO: Send out cancellations for all pending docs
await prisma.envelope.updateMany({
where: {
userId: user.id,
type: EnvelopeType.DOCUMENT,
status: {
in: [DocumentStatus.PENDING, DocumentStatus.REJECTED, DocumentStatus.COMPLETED],
},
},
data: {
userId: serviceAccount.id,
deletedAt: new Date(),
},
});
// Get team IDs from organisations the user is a member of (but not owner).
const memberTeams = user.organisationMember
.filter((member) => member.organisation.ownerUserId !== user.id)
.flatMap((member) =>
member.organisation.teams.map((team) => ({
teamId: team.id,
orgOwnerId: member.organisation.ownerUserId,
})),
);
// For teams where user is the org owner - orphan their envelopes.
await Promise.all(ownedTeamIds.map(async (teamId) => orphanEnvelopes({ teamId })));
// For teams where user is a member (not owner) - transfer envelopes to team owner.
await Promise.all(
memberTeams.map(async ({ teamId, orgOwnerId }) => {
return prisma.envelope.updateMany({
where: {
userId: user.id,
teamId,
},
data: {
userId: orgOwnerId,
},
});
}),
);
return await prisma.user.delete({
where: {
@@ -5,6 +5,20 @@ export const deletedAccountServiceAccount = async () => {
where: {
email: 'deleted-account@documenso.com',
},
select: {
id: true,
email: true,
ownedOrganisations: {
select: {
id: true,
teams: {
select: {
id: true,
},
},
},
},
},
});
if (!serviceAccount) {
@@ -3,6 +3,7 @@ import {
ORGANISATION_USER_ACCOUNT_TYPE,
} from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { orphanEnvelopes } from '@documenso/lib/server-only/envelope/orphan-envelopes';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
@@ -32,6 +33,19 @@ export const deleteOrganisationRoute = authenticatedProcedure
userId: user.id,
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['DELETE_ORGANISATION'],
}),
select: {
id: true,
owner: {
select: {
id: true,
},
},
teams: {
select: {
id: true,
},
},
},
});
if (!organisation) {
@@ -40,6 +54,9 @@ export const deleteOrganisationRoute = authenticatedProcedure
});
}
// Orphan all envelopes to get rid of foreign key constraints.
await Promise.all(organisation.teams.map(async (team) => orphanEnvelopes({ teamId: team.id })));
await prisma.$transaction(async (tx) => {
await tx.account.deleteMany({
where: {
@@ -1,4 +1,10 @@
import { TeamMemberRole } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { orphanEnvelopes } from '@documenso/lib/server-only/envelope/orphan-envelopes';
import { transferTeamEnvelopes } from '@documenso/lib/server-only/envelope/transfer-team-envelopes';
import { deleteTeam } from '@documenso/lib/server-only/team/delete-team';
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
import { authenticatedProcedure } from '../trpc';
import { ZDeleteTeamRequestSchema, ZDeleteTeamResponseSchema } from './delete-team.types';
@@ -8,15 +14,40 @@ export const deleteTeamRoute = authenticatedProcedure
.input(ZDeleteTeamRequestSchema)
.output(ZDeleteTeamResponseSchema)
.mutation(async ({ input, ctx }) => {
const { teamId } = input;
const { teamId, transferTeamId } = input;
const { user } = ctx;
const team = await getTeamById({ userId: user.id, teamId });
if (team.currentTeamRole !== TeamMemberRole.ADMIN) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You are not allowed to delete this team',
});
}
ctx.logger.info({
input: {
teamId,
},
});
const transferTeam = transferTeamId
? await getTeamById({ userId: user.id, teamId: transferTeamId }).catch(() => {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Invalid transfer team ID',
});
})
: undefined;
if (transferTeam) {
await transferTeamEnvelopes({
sourceTeamId: teamId,
targetTeamId: transferTeam.id,
});
} else {
await orphanEnvelopes({ teamId });
}
await deleteTeam({
userId: user.id,
teamId,
@@ -12,6 +12,7 @@ import { z } from 'zod';
export const ZDeleteTeamRequestSchema = z.object({
teamId: z.number(),
transferTeamId: z.number().optional(),
});
export const ZDeleteTeamResponseSchema = z.void();