mirror of
https://github.com/documenso/documenso.git
synced 2026-07-24 17:04:12 +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 { useNavigate } from 'react-router';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { AppError } from '@documenso/lib/errors/app-error';
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
@@ -30,6 +31,13 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@documenso/ui/primitives/form/form';
|
} from '@documenso/ui/primitives/form/form';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
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 type { Toast } from '@documenso/ui/primitives/use-toast';
|
||||||
import { useToast } 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 { toast } = useToast();
|
||||||
const { refreshSession } = useSession();
|
const { refreshSession } = useSession();
|
||||||
|
|
||||||
|
const currentOrganisation = useCurrentOrganisation();
|
||||||
|
|
||||||
const deleteMessage = _(msg`delete ${teamName}`);
|
const deleteMessage = _(msg`delete ${teamName}`);
|
||||||
|
|
||||||
|
const filteredTeams = currentOrganisation.teams.filter((team) => team.id !== teamId);
|
||||||
|
|
||||||
const ZDeleteTeamFormSchema = z.object({
|
const ZDeleteTeamFormSchema = z.object({
|
||||||
teamName: z.literal(deleteMessage, {
|
teamName: z.literal(deleteMessage, {
|
||||||
errorMap: () => ({ message: _(msg`You must enter '${deleteMessage}' to proceed`) }),
|
errorMap: () => ({ message: _(msg`You must enter '${deleteMessage}' to proceed`) }),
|
||||||
}),
|
}),
|
||||||
|
transferTeamId: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
resolver: zodResolver(ZDeleteTeamFormSchema),
|
resolver: zodResolver(ZDeleteTeamFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
teamName: '',
|
teamName: '',
|
||||||
|
transferTeamId: undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { mutateAsync: deleteTeam } = trpc.team.delete.useMutation();
|
const { mutateAsync: deleteTeam } = trpc.team.delete.useMutation();
|
||||||
|
|
||||||
const onFormSubmit = async () => {
|
const onFormSubmit = async (data: z.infer<typeof ZDeleteTeamFormSchema>) => {
|
||||||
try {
|
try {
|
||||||
await deleteTeam({ teamId });
|
const transferTeamId = data.transferTeamId ? parseInt(data.transferTeamId, 10) : undefined;
|
||||||
|
|
||||||
|
await deleteTeam({ teamId, transferTeamId: transferTeamId || undefined });
|
||||||
|
|
||||||
await refreshSession();
|
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>
|
<DialogFooter>
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||||
<Trans>Cancel</Trans>
|
<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 { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||||
import { deletedAccountServiceAccount } from './service-accounts/deleted-account';
|
import { orphanEnvelopes } from '../envelope/orphan-envelopes';
|
||||||
|
|
||||||
export type DeleteUserOptions = {
|
export type DeleteUserOptions = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -14,6 +12,30 @@ export const deleteUser = async ({ id }: DeleteUserOptions) => {
|
|||||||
where: {
|
where: {
|
||||||
id,
|
id,
|
||||||
},
|
},
|
||||||
|
include: {
|
||||||
|
ownedOrganisations: {
|
||||||
|
include: {
|
||||||
|
teams: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
organisationMember: {
|
||||||
|
include: {
|
||||||
|
organisation: {
|
||||||
|
include: {
|
||||||
|
teams: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
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
|
// Get team IDs from organisations the user is a member of (but not owner).
|
||||||
await prisma.envelope.updateMany({
|
const memberTeams = user.organisationMember
|
||||||
where: {
|
.filter((member) => member.organisation.ownerUserId !== user.id)
|
||||||
userId: user.id,
|
.flatMap((member) =>
|
||||||
type: EnvelopeType.DOCUMENT,
|
member.organisation.teams.map((team) => ({
|
||||||
status: {
|
teamId: team.id,
|
||||||
in: [DocumentStatus.PENDING, DocumentStatus.REJECTED, DocumentStatus.COMPLETED],
|
orgOwnerId: member.organisation.ownerUserId,
|
||||||
},
|
})),
|
||||||
},
|
);
|
||||||
data: {
|
|
||||||
userId: serviceAccount.id,
|
// For teams where user is the org owner - orphan their envelopes.
|
||||||
deletedAt: new Date(),
|
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({
|
return await prisma.user.delete({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@@ -5,6 +5,20 @@ export const deletedAccountServiceAccount = async () => {
|
|||||||
where: {
|
where: {
|
||||||
email: 'deleted-account@documenso.com',
|
email: 'deleted-account@documenso.com',
|
||||||
},
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
ownedOrganisations: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
teams: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!serviceAccount) {
|
if (!serviceAccount) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
ORGANISATION_USER_ACCOUNT_TYPE,
|
ORGANISATION_USER_ACCOUNT_TYPE,
|
||||||
} from '@documenso/lib/constants/organisations';
|
} from '@documenso/lib/constants/organisations';
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
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 { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
@@ -32,6 +33,19 @@ export const deleteOrganisationRoute = authenticatedProcedure
|
|||||||
userId: user.id,
|
userId: user.id,
|
||||||
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['DELETE_ORGANISATION'],
|
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['DELETE_ORGANISATION'],
|
||||||
}),
|
}),
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
owner: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
teams: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!organisation) {
|
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 prisma.$transaction(async (tx) => {
|
||||||
await tx.account.deleteMany({
|
await tx.account.deleteMany({
|
||||||
where: {
|
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 { deleteTeam } from '@documenso/lib/server-only/team/delete-team';
|
||||||
|
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
|
||||||
|
|
||||||
import { authenticatedProcedure } from '../trpc';
|
import { authenticatedProcedure } from '../trpc';
|
||||||
import { ZDeleteTeamRequestSchema, ZDeleteTeamResponseSchema } from './delete-team.types';
|
import { ZDeleteTeamRequestSchema, ZDeleteTeamResponseSchema } from './delete-team.types';
|
||||||
@@ -8,15 +14,40 @@ export const deleteTeamRoute = authenticatedProcedure
|
|||||||
.input(ZDeleteTeamRequestSchema)
|
.input(ZDeleteTeamRequestSchema)
|
||||||
.output(ZDeleteTeamResponseSchema)
|
.output(ZDeleteTeamResponseSchema)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const { teamId } = input;
|
const { teamId, transferTeamId } = input;
|
||||||
const { user } = ctx;
|
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({
|
ctx.logger.info({
|
||||||
input: {
|
input: {
|
||||||
teamId,
|
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({
|
await deleteTeam({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
teamId,
|
teamId,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
export const ZDeleteTeamRequestSchema = z.object({
|
export const ZDeleteTeamRequestSchema = z.object({
|
||||||
teamId: z.number(),
|
teamId: z.number(),
|
||||||
|
transferTeamId: z.number().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ZDeleteTeamResponseSchema = z.void();
|
export const ZDeleteTeamResponseSchema = z.void();
|
||||||
|
|||||||
Reference in New Issue
Block a user