mirror of
https://github.com/documenso/documenso.git
synced 2026-07-09 20:45:00 +10:00
feat: unlink documents from deleted organization (#2006)
This commit is contained in:
@@ -8,6 +8,7 @@ import { useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
@@ -30,6 +31,13 @@ import {
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
import type { Toast } from '@documenso/ui/primitives/use-toast';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
@@ -53,26 +61,34 @@ export const TeamDeleteDialog = ({
|
||||
const { toast } = useToast();
|
||||
const { refreshSession } = useSession();
|
||||
|
||||
const currentOrganisation = useCurrentOrganisation();
|
||||
|
||||
const deleteMessage = _(msg`delete ${teamName}`);
|
||||
|
||||
const filteredTeams = currentOrganisation.teams.filter((team) => team.id !== teamId);
|
||||
|
||||
const ZDeleteTeamFormSchema = z.object({
|
||||
teamName: z.literal(deleteMessage, {
|
||||
errorMap: () => ({ message: _(msg`You must enter '${deleteMessage}' to proceed`) }),
|
||||
}),
|
||||
transferTeamId: z.string().optional(),
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(ZDeleteTeamFormSchema),
|
||||
defaultValues: {
|
||||
teamName: '',
|
||||
transferTeamId: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: deleteTeam } = trpc.team.delete.useMutation();
|
||||
|
||||
const onFormSubmit = async () => {
|
||||
const onFormSubmit = async (data: z.infer<typeof ZDeleteTeamFormSchema>) => {
|
||||
try {
|
||||
await deleteTeam({ teamId });
|
||||
const transferTeamId = data.transferTeamId ? parseInt(data.transferTeamId, 10) : undefined;
|
||||
|
||||
await deleteTeam({ teamId, transferTeamId: transferTeamId || undefined });
|
||||
|
||||
await refreshSession();
|
||||
|
||||
@@ -168,6 +184,43 @@ export const TeamDeleteDialog = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
{filteredTeams.length > 0 && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="transferTeamId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Transfer documents to a different team</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select {...field} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={_(msg`Don't transfer (Delete all documents)`)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectItem value="-1">
|
||||
<Trans>Don't transfer (Delete all documents)</Trans>
|
||||
</SelectItem>
|
||||
|
||||
{filteredTeams.map((team) => (
|
||||
<SelectItem key={team.id} value={team.id.toString()}>
|
||||
{team.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user