diff --git a/apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx b/apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx new file mode 100644 index 000000000..5ae6f1c0a --- /dev/null +++ b/apps/remix/app/components/dialogs/admin-organisation-delete-dialog.tsx @@ -0,0 +1,188 @@ +import { AppError } from '@documenso/lib/errors/app-error'; +import { trpc } from '@documenso/trpc/react'; +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { Checkbox } from '@documenso/ui/primitives/checkbox'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { useToast } from '@documenso/ui/primitives/use-toast'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Trans, useLingui } from '@lingui/react/macro'; +import { useEffect, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +export type AdminOrganisationDeleteDialogProps = { + organisationId: string; + organisationName: string; + trigger?: React.ReactNode; +}; + +export const AdminOrganisationDeleteDialog = ({ + organisationId, + organisationName, + trigger, +}: AdminOrganisationDeleteDialogProps) => { + const [open, setOpen] = useState(false); + + const { t } = useLingui(); + const { toast } = useToast(); + + const deleteMessage = t`delete ${organisationName}`; + + const ZAdminDeleteOrganisationFormSchema = z.object({ + organisationName: z.literal(deleteMessage, { + errorMap: () => ({ message: t`You must enter '${deleteMessage}' to proceed` }), + }), + sendEmailToOwner: z.boolean(), + }); + + type TAdminDeleteOrganisationFormSchema = z.infer; + + const form = useForm({ + resolver: zodResolver(ZAdminDeleteOrganisationFormSchema), + defaultValues: { + organisationName: '', + sendEmailToOwner: true, + }, + }); + + const { mutateAsync: deleteOrganisation } = trpc.admin.organisation.delete.useMutation(); + + const onFormSubmit = async (values: TAdminDeleteOrganisationFormSchema) => { + try { + await deleteOrganisation({ + organisationId, + organisationName, + sendEmailToOwner: values.sendEmailToOwner, + }); + + toast({ + title: t`Deletion scheduled`, + description: t`The organisation will be deleted in the background. Documents will be orphaned, not deleted.`, + duration: 7500, + }); + + setOpen(false); + } catch (err) { + const error = AppError.parseError(err); + console.error(error); + + toast({ + title: t`An error occurred`, + description: t`We encountered an error while attempting to delete this organisation. Please try again later.`, + variant: 'destructive', + duration: 10000, + }); + } + }; + + useEffect(() => { + if (!open) { + form.reset(); + } + }, [open, form]); + + return ( + !form.formState.isSubmitting && setOpen(value)}> + + {trigger ?? ( + + )} + + + + + + Delete organisation + + + + + You are about to delete {organisationName}. This action is not + reversible. All teams will be removed and all documents will be orphaned to the deleted-account service + account. + + + + + + + + The deletion will run in the background, and can take up to a few minutes to complete. Do not re-run this + deletion. + + + + +
+ +
+ ( + + + + Confirm by typing {deleteMessage} + + + + + + + + )} + /> + + ( + + + + + + + + )} + /> + + + + + + +
+
+ +
+
+ ); +}; diff --git a/apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx b/apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx index 2885fbd9a..64a6839f8 100644 --- a/apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +++ b/apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx @@ -37,6 +37,7 @@ import { Link, useNavigate } from 'react-router'; import { match } from 'ts-pattern'; import type { z } from 'zod'; +import { AdminOrganisationDeleteDialog } from '~/components/dialogs/admin-organisation-delete-dialog'; import { AdminOrganisationMemberDeleteDialog } from '~/components/dialogs/admin-organisation-member-delete-dialog'; import { AdminOrganisationMemberUpdateDialog } from '~/components/dialogs/admin-organisation-member-update-dialog'; import { DetailsCard, DetailsValue } from '~/components/general/admin-details'; @@ -64,9 +65,14 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro const organisationId = params.id; - const { data: organisation, isLoading: isLoadingOrganisation } = trpc.admin.organisation.get.useQuery({ - organisationId, - }); + const { data: organisation, isLoading: isLoadingOrganisation } = trpc.admin.organisation.get.useQuery( + { + organisationId, + }, + { + retry: false, + }, + ); const { mutateAsync: createStripeCustomer, isPending: isCreatingStripeCustomer } = trpc.admin.stripe.createCustomer.useMutation({ @@ -398,6 +404,31 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro + + + + +
+ + Delete organisation + + + + + Permanently delete this organisation. Documents will be orphaned (not deleted) so they remain accessible + via the deleted-account service account. + + +
+ +
+ +
+
); } diff --git a/packages/app-tests/e2e/admin/organisations/delete-organisation.spec.ts b/packages/app-tests/e2e/admin/organisations/delete-organisation.spec.ts new file mode 100644 index 000000000..aa44a8f0e --- /dev/null +++ b/packages/app-tests/e2e/admin/organisations/delete-organisation.spec.ts @@ -0,0 +1,574 @@ +import { prisma } from '@documenso/prisma'; +import { BackgroundJobStatus, DocumentStatus, EnvelopeType, Role } from '@documenso/prisma/client'; +import { seedBlankDocument } from '@documenso/prisma/seed/documents'; +import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations'; +import { seedUser } from '@documenso/prisma/seed/users'; +import { expect, test } from '@playwright/test'; + +import { apiSignin, apiSignout } from '../../fixtures/authentication'; + +/** + * Helper that polls until the `admin.organisation.delete` background job for the + * supplied organisation has finished (status COMPLETED). Returns the org id. + */ +const waitForOrganisationDeletionJob = async (organisationId: string) => { + await expect + .poll( + async () => { + const job = await prisma.backgroundJob.findFirst({ + where: { + jobId: 'internal.admin-delete-organisation', + // payload is JSON; match the organisationId field. + payload: { + path: ['organisationId'], + equals: organisationId, + }, + }, + orderBy: { submittedAt: 'desc' }, + }); + + return job?.status ?? null; + }, + { + message: `Background deletion job for organisation ${organisationId} did not complete in time`, + timeout: 30_000, + intervals: [250, 500, 1000], + }, + ) + .toBe(BackgroundJobStatus.COMPLETED); +}; + +const waitForOrganisationToBeGone = async (organisationId: string) => { + await expect + .poll( + async () => { + const org = await prisma.organisation.findUnique({ + where: { id: organisationId }, + select: { id: true }, + }); + + return org === null; + }, + { + message: `Organisation ${organisationId} was not removed`, + timeout: 30_000, + intervals: [250, 500, 1000], + }, + ) + .toBe(true); +}; + +test.describe.configure({ mode: 'parallel' }); + +// ─── Happy path ────────────────────────────────────────────────────────────── + +test('[ADMIN][DELETE_ORG]: admin can delete an organisation via the dialog', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + const { organisation } = await seedUser({ isPersonalOrganisation: false }); + + await apiSignin({ + page, + email: adminUser.email, + redirectPath: `/admin/organisations/${organisation.id}`, + }); + + await expect(page.getByRole('heading', { name: 'Danger Zone' })).toBeVisible(); + + // Open the dialog + await page.getByRole('button', { name: 'Delete' }).first().click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + + // The Delete submit button is initially enabled but submission should fail + // until the confirmation text matches. Type it now. + await dialog.getByRole('textbox').fill(`delete ${organisation.name}`); + + // The "send email to owner" checkbox should be checked by default. + const emailCheckbox = dialog.getByRole('checkbox'); + await expect(emailCheckbox).toBeChecked(); + + await dialog.getByRole('button', { name: 'Delete', exact: true }).click(); + + // Dialog closes on success + await expect(dialog).not.toBeVisible({ timeout: 10_000 }); + + // Background job completes and the org is removed + await waitForOrganisationDeletionJob(organisation.id); + await waitForOrganisationToBeGone(organisation.id); +}); + +// ─── Confirmation text validation ──────────────────────────────────────────── + +test('[ADMIN][DELETE_ORG]: typing the wrong confirmation text prevents deletion', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + const { organisation } = await seedUser({ isPersonalOrganisation: false }); + + await apiSignin({ + page, + email: adminUser.email, + redirectPath: `/admin/organisations/${organisation.id}`, + }); + + await page.getByRole('button', { name: 'Delete' }).first().click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + + // Type something that does NOT match. + await dialog.getByRole('textbox').fill('delete wrong-name'); + + await dialog.getByRole('button', { name: 'Delete', exact: true }).click(); + + // Validation message should appear and the dialog should stay open. + await expect(dialog.getByText(/You must enter/)).toBeVisible(); + await expect(dialog).toBeVisible(); + + // Org is still there. + const stillExists = await prisma.organisation.findUnique({ where: { id: organisation.id } }); + expect(stillExists).not.toBeNull(); +}); + +test('[ADMIN][DELETE_ORG]: empty confirmation text prevents deletion', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + const { organisation } = await seedUser({ isPersonalOrganisation: false }); + + await apiSignin({ + page, + email: adminUser.email, + redirectPath: `/admin/organisations/${organisation.id}`, + }); + + await page.getByRole('button', { name: 'Delete' }).first().click(); + + const dialog = page.getByRole('dialog'); + await dialog.getByRole('button', { name: 'Delete', exact: true }).click(); + + await expect(dialog.getByText(/You must enter/)).toBeVisible(); + await expect(dialog).toBeVisible(); + + const stillExists = await prisma.organisation.findUnique({ where: { id: organisation.id } }); + expect(stillExists).not.toBeNull(); +}); + +// ─── Cancel ────────────────────────────────────────────────────────────────── + +test('[ADMIN][DELETE_ORG]: clicking Cancel closes the dialog without deleting', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + const { organisation } = await seedUser({ isPersonalOrganisation: false }); + + await apiSignin({ + page, + email: adminUser.email, + redirectPath: `/admin/organisations/${organisation.id}`, + }); + + await page.getByRole('button', { name: 'Delete' }).first().click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + + // Fill in the correct text but cancel anyway. + await dialog.getByRole('textbox').fill(`delete ${organisation.name}`); + await dialog.getByRole('button', { name: 'Cancel' }).click(); + + await expect(dialog).not.toBeVisible(); + + // Org still there. + const stillExists = await prisma.organisation.findUnique({ where: { id: organisation.id } }); + expect(stillExists).not.toBeNull(); +}); + +// ─── Email checkbox ────────────────────────────────────────────────────────── + +test('[ADMIN][DELETE_ORG]: email checkbox can be unchecked, payload reflects choice', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + const { organisation } = await seedUser({ isPersonalOrganisation: false }); + + await apiSignin({ + page, + email: adminUser.email, + redirectPath: `/admin/organisations/${organisation.id}`, + }); + + await page.getByRole('button', { name: 'Delete' }).first().click(); + + const dialog = page.getByRole('dialog'); + const emailCheckbox = dialog.getByRole('checkbox'); + + // Default is checked. + await expect(emailCheckbox).toBeChecked(); + + // Uncheck it. + await emailCheckbox.click(); + await expect(emailCheckbox).not.toBeChecked(); + + await dialog.getByRole('textbox').fill(`delete ${organisation.name}`); + await dialog.getByRole('button', { name: 'Delete', exact: true }).click(); + + await expect(dialog).not.toBeVisible({ timeout: 10_000 }); + + // Verify the enqueued job payload has sendEmailToOwner=false. + await expect + .poll( + async () => { + const job = await prisma.backgroundJob.findFirst({ + where: { + jobId: 'internal.admin-delete-organisation', + payload: { path: ['organisationId'], equals: organisation.id }, + }, + }); + + if (!job) { + return null; + } + + const payload = job.payload as { sendEmailToOwner?: boolean }; + return payload.sendEmailToOwner; + }, + { timeout: 15_000 }, + ) + .toBe(false); + + await waitForOrganisationDeletionJob(organisation.id); + await waitForOrganisationToBeGone(organisation.id); +}); + +// ─── Documents are orphaned, not deleted ───────────────────────────────────── + +test('[ADMIN][DELETE_ORG]: envelopes authored by owner and members are orphaned, drafts are removed', async ({ + page, +}) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + const { user: owner, organisation, team } = await seedUser({ isPersonalOrganisation: false }); + + // Add two organisation members who will author their own envelopes. + const [memberUser, managerUser] = await seedOrganisationMembers({ + organisationId: organisation.id, + members: [{ organisationRole: 'MEMBER' }, { organisationRole: 'MANAGER' }], + }); + + // ── Owner-authored envelopes ────────────────────────────────────────────── + const ownerCompleted = await seedBlankDocument(owner, team.id, { key: 'owner-completed' }); + await prisma.envelope.update({ + where: { id: ownerCompleted.id }, + data: { status: DocumentStatus.COMPLETED }, + }); + + const ownerPending = await seedBlankDocument(owner, team.id, { key: 'owner-pending' }); + await prisma.envelope.update({ + where: { id: ownerPending.id }, + data: { status: DocumentStatus.PENDING }, + }); + + const ownerDraft = await seedBlankDocument(owner, team.id, { key: 'owner-draft' }); + + // ── Member-authored envelopes ───────────────────────────────────────────── + const memberCompleted = await seedBlankDocument(memberUser, team.id, { key: 'member-completed' }); + await prisma.envelope.update({ + where: { id: memberCompleted.id }, + data: { status: DocumentStatus.COMPLETED }, + }); + + const memberPending = await seedBlankDocument(memberUser, team.id, { key: 'member-pending' }); + await prisma.envelope.update({ + where: { id: memberPending.id }, + data: { status: DocumentStatus.PENDING }, + }); + + const memberDraft = await seedBlankDocument(memberUser, team.id, { key: 'member-draft' }); + + // ── Manager-authored envelope (third author for good measure) ───────────── + const managerRejected = await seedBlankDocument(managerUser, team.id, { key: 'manager-rejected' }); + await prisma.envelope.update({ + where: { id: managerRejected.id }, + data: { status: DocumentStatus.REJECTED }, + }); + + // Sanity check: before deletion all 7 envelopes belong to the team and + // retain their original authors. + const beforeCount = await prisma.envelope.count({ where: { teamId: team.id } }); + expect(beforeCount).toBe(7); + + // ── Trigger the deletion via the admin UI ───────────────────────────────── + await apiSignin({ + page, + email: adminUser.email, + redirectPath: `/admin/organisations/${organisation.id}`, + }); + + await page.getByRole('button', { name: 'Delete' }).first().click(); + const dialog = page.getByRole('dialog'); + await dialog.getByRole('textbox').fill(`delete ${organisation.name}`); + await dialog.getByRole('button', { name: 'Delete', exact: true }).click(); + await expect(dialog).not.toBeVisible({ timeout: 10_000 }); + + await waitForOrganisationDeletionJob(organisation.id); + await waitForOrganisationToBeGone(organisation.id); + + // The deleted-account service account is where orphaned envelopes land. + const deletedAccount = await prisma.user.findFirstOrThrow({ + where: { email: { startsWith: 'deleted-account@' } }, + select: { id: true, ownedOrganisations: { select: { teams: { select: { id: true } } } } }, + }); + const deletedAccountTeamId = deletedAccount.ownedOrganisations[0].teams[0].id; + + // ── Owner-authored envelopes ────────────────────────────────────────────── + // Completed/pending: orphaned (reparented to service account + deletedAt set). + for (const original of [ownerCompleted, ownerPending]) { + const after = await prisma.envelope.findUnique({ + where: { id: original.id }, + select: { id: true, teamId: true, userId: true, deletedAt: true }, + }); + expect(after, `owner envelope ${original.id} should survive as orphan`).not.toBeNull(); + expect(after?.teamId).toBe(deletedAccountTeamId); + expect(after?.userId).toBe(deletedAccount.id); + expect(after?.deletedAt).not.toBeNull(); + } + + // Draft: hard-deleted because orphan only re-parents PENDING/REJECTED/COMPLETED. + const ownerDraftAfter = await prisma.envelope.findUnique({ + where: { id: ownerDraft.id }, + select: { id: true }, + }); + expect(ownerDraftAfter, 'owner draft should be hard-deleted').toBeNull(); + + // ── Member-authored envelopes (the critical case) ───────────────────────── + // The orphan logic filters by teamId only — NOT by userId — so member-authored + // envelopes must be orphaned just like the owner's. + for (const original of [memberCompleted, memberPending]) { + const after = await prisma.envelope.findUnique({ + where: { id: original.id }, + select: { id: true, teamId: true, userId: true, deletedAt: true }, + }); + expect(after, `member envelope ${original.id} should survive as orphan`).not.toBeNull(); + expect(after?.teamId).toBe(deletedAccountTeamId); + expect(after?.userId).toBe(deletedAccount.id); + expect(after?.deletedAt).not.toBeNull(); + } + + const memberDraftAfter = await prisma.envelope.findUnique({ + where: { id: memberDraft.id }, + select: { id: true }, + }); + expect(memberDraftAfter, 'member draft should be hard-deleted').toBeNull(); + + // ── Manager-authored rejected envelope: also orphaned ───────────────────── + const managerRejectedAfter = await prisma.envelope.findUnique({ + where: { id: managerRejected.id }, + select: { id: true, teamId: true, userId: true, deletedAt: true }, + }); + expect(managerRejectedAfter).not.toBeNull(); + expect(managerRejectedAfter?.teamId).toBe(deletedAccountTeamId); + expect(managerRejectedAfter?.userId).toBe(deletedAccount.id); + + // ── Original team is gone, member users still exist ─────────────────────── + const teamAfter = await prisma.team.findUnique({ where: { id: team.id } }); + expect(teamAfter).toBeNull(); + + // No envelope should reference the now-deleted team. + const orphanedToOldTeam = await prisma.envelope.count({ where: { teamId: team.id } }); + expect(orphanedToOldTeam).toBe(0); + + // The owner and members survive — only the org is deleted, not the users. + const ownerAfter = await prisma.user.findUnique({ where: { id: owner.id } }); + const memberAfter = await prisma.user.findUnique({ where: { id: memberUser.id } }); + const managerAfter = await prisma.user.findUnique({ where: { id: managerUser.id } }); + expect(ownerAfter).not.toBeNull(); + expect(memberAfter).not.toBeNull(); + expect(managerAfter).not.toBeNull(); +}); + +// ─── Owner can no longer access the deleted organisation ───────────────────── + +test('[ADMIN][DELETE_ORG]: the original owner loses access after deletion', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + const { user: owner, organisation } = await seedUser({ isPersonalOrganisation: false }); + + await apiSignin({ + page, + email: adminUser.email, + redirectPath: `/admin/organisations/${organisation.id}`, + }); + + await page.getByRole('button', { name: 'Delete' }).first().click(); + const dialog = page.getByRole('dialog'); + await dialog.getByRole('textbox').fill(`delete ${organisation.name}`); + await dialog.getByRole('button', { name: 'Delete', exact: true }).click(); + await expect(dialog).not.toBeVisible({ timeout: 10_000 }); + + await waitForOrganisationDeletionJob(organisation.id); + await waitForOrganisationToBeGone(organisation.id); + + // Sign in as the original owner and confirm they can no longer reach the + // organisation settings page. + await apiSignout({ page }); + + await apiSignin({ + page, + email: owner.email, + redirectPath: `/o/${organisation.url}/settings/general`, + }); + + // They should NOT see the organisation settings heading for this org. + await expect(page.getByText('Organisation Settings')).not.toBeVisible(); +}); + +// ─── Access control: UI ────────────────────────────────────────────────────── + +test('[ADMIN][DELETE_ORG]: non-admin user cannot access /admin/organisations/$id', async ({ page }) => { + const { user: nonAdminUser } = await seedUser({ isAdmin: false }); + const { organisation } = await seedUser({ isPersonalOrganisation: false }); + + await apiSignin({ + page, + email: nonAdminUser.email, + redirectPath: `/admin/organisations/${organisation.id}`, + }); + + // The admin layout loader redirects non-admins to "/". They must not see the + // admin panel or any Delete affordance. + await expect(page.getByRole('heading', { name: 'Admin Panel' })).not.toBeVisible(); + await expect(page.getByRole('heading', { name: 'Danger Zone' })).not.toBeVisible(); + await expect(page.getByRole('button', { name: 'Delete' })).not.toBeVisible(); + + // The org must still exist. + const stillExists = await prisma.organisation.findUnique({ where: { id: organisation.id } }); + expect(stillExists).not.toBeNull(); +}); + +test('[ADMIN][DELETE_ORG]: unauthenticated user cannot access /admin/organisations/$id', async ({ page }) => { + const { organisation } = await seedUser({ isPersonalOrganisation: false }); + + // No apiSignin call. Navigate directly. + await page.goto(`/admin/organisations/${organisation.id}`); + + // Unauthenticated requests should be redirected away from any /admin/* route. + await expect(page).not.toHaveURL(new RegExp(`/admin/organisations/${organisation.id}`)); + await expect(page.getByRole('heading', { name: 'Danger Zone' })).not.toBeVisible(); + + const stillExists = await prisma.organisation.findUnique({ where: { id: organisation.id } }); + expect(stillExists).not.toBeNull(); +}); + +// ─── Belt-and-braces: organisation owner (without admin role) can't use it ── + +test('[ADMIN][DELETE_ORG]: an organisation owner without admin role cannot reach the admin delete UI', async ({ + page, +}) => { + const { user: owner, organisation } = await seedUser({ isPersonalOrganisation: false }); + + // Confirm the owner is NOT an admin (sanity check on the seed). + expect(owner.roles).not.toContain(Role.ADMIN); + + await apiSignin({ + page, + email: owner.email, + redirectPath: `/admin/organisations/${organisation.id}`, + }); + + await expect(page.getByRole('heading', { name: 'Danger Zone' })).not.toBeVisible(); + await expect(page.getByRole('button', { name: 'Delete' })).not.toBeVisible(); + + const stillExists = await prisma.organisation.findUnique({ where: { id: organisation.id } }); + expect(stillExists).not.toBeNull(); +}); + +// ─── Org with multiple members triggers email to the OWNER only ───────────── + +test('[ADMIN][DELETE_ORG]: job payload targets the organisation owner for the email notification', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + const { user: owner, organisation } = await seedUser({ isPersonalOrganisation: false }); + + await seedOrganisationMembers({ + organisationId: organisation.id, + members: [{ organisationRole: 'MEMBER' }, { organisationRole: 'ADMIN' }, { organisationRole: 'MANAGER' }], + }); + + await apiSignin({ + page, + email: adminUser.email, + redirectPath: `/admin/organisations/${organisation.id}`, + }); + + await page.getByRole('button', { name: 'Delete' }).first().click(); + const dialog = page.getByRole('dialog'); + await dialog.getByRole('textbox').fill(`delete ${organisation.name}`); + await dialog.getByRole('button', { name: 'Delete', exact: true }).click(); + await expect(dialog).not.toBeVisible({ timeout: 10_000 }); + + // The job payload should record the admin who requested the delete and + // sendEmailToOwner=true. (Verifying the actual email send is out of scope + // for this test; we verify the payload only.) + await expect + .poll( + async () => { + const job = await prisma.backgroundJob.findFirst({ + where: { + jobId: 'internal.admin-delete-organisation', + payload: { path: ['organisationId'], equals: organisation.id }, + }, + }); + + if (!job) { + return null; + } + + const payload = job.payload as { + sendEmailToOwner?: boolean; + requestedByUserId?: number; + }; + + return payload; + }, + { timeout: 15_000 }, + ) + .toMatchObject({ + sendEmailToOwner: true, + requestedByUserId: adminUser.id, + }); + + await waitForOrganisationDeletionJob(organisation.id); + await waitForOrganisationToBeGone(organisation.id); + + // Owner user record itself is NOT deleted — only the org. + const ownerStillExists = await prisma.user.findUnique({ where: { id: owner.id } }); + expect(ownerStillExists).not.toBeNull(); +}); + +// ─── EnvelopeType.TEMPLATE is also cleaned up via orphan flow ─────────────── + +test('[ADMIN][DELETE_ORG]: template envelopes are removed (not orphaned)', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + const { user: owner, organisation, team } = await seedUser({ isPersonalOrganisation: false }); + + // Create a TEMPLATE envelope. orphanEnvelopes only re-parents DOCUMENT + // envelopes; templates fall into the "deleteMany" path. + const draftDoc = await seedBlankDocument(owner, team.id, { key: 'tmpl' }); + await prisma.envelope.update({ + where: { id: draftDoc.id }, + data: { type: EnvelopeType.TEMPLATE }, + }); + + await apiSignin({ + page, + email: adminUser.email, + redirectPath: `/admin/organisations/${organisation.id}`, + }); + + await page.getByRole('button', { name: 'Delete' }).first().click(); + const dialog = page.getByRole('dialog'); + await dialog.getByRole('textbox').fill(`delete ${organisation.name}`); + await dialog.getByRole('button', { name: 'Delete', exact: true }).click(); + await expect(dialog).not.toBeVisible({ timeout: 10_000 }); + + await waitForOrganisationDeletionJob(organisation.id); + await waitForOrganisationToBeGone(organisation.id); + + const templateAfter = await prisma.envelope.findUnique({ + where: { id: draftDoc.id }, + select: { id: true }, + }); + + expect(templateAfter).toBeNull(); +}); diff --git a/packages/app-tests/e2e/api/trpc/admin/delete-organisation.spec.ts b/packages/app-tests/e2e/api/trpc/admin/delete-organisation.spec.ts new file mode 100644 index 000000000..b2383be2f --- /dev/null +++ b/packages/app-tests/e2e/api/trpc/admin/delete-organisation.spec.ts @@ -0,0 +1,252 @@ +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { prisma } from '@documenso/prisma'; +import { seedUser } from '@documenso/prisma/seed/users'; +import type { Page } from '@playwright/test'; +import { expect, test } from '@playwright/test'; + +import { apiSignin } from '../../../fixtures/authentication'; + +const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL(); + +test.describe.configure({ mode: 'parallel' }); + +const callDeleteOrganisation = async ( + page: Page, + input: { + organisationId: string; + organisationName: string; + sendEmailToOwner: boolean; + }, +) => { + return await page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/admin.organisation.delete`, { + headers: { 'content-type': 'application/json' }, + data: JSON.stringify({ json: input }), + }); +}; + +// ─── Access control ────────────────────────────────────────────────────────── + +test('[ADMIN][TRPC][DELETE_ORG]: unauthenticated request is rejected with 401', async ({ page }) => { + const { organisation } = await seedUser({ isPersonalOrganisation: false }); + + // No sign-in. + const res = await callDeleteOrganisation(page, { + organisationId: organisation.id, + organisationName: organisation.name, + sendEmailToOwner: true, + }); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(401); + + // Org must still exist. + const stillExists = await prisma.organisation.findUnique({ where: { id: organisation.id } }); + expect(stillExists).not.toBeNull(); + + // No deletion job must have been enqueued. + const job = await prisma.backgroundJob.findFirst({ + where: { + jobId: 'internal.admin-delete-organisation', + payload: { path: ['organisationId'], equals: organisation.id }, + }, + }); + expect(job).toBeNull(); +}); + +test('[ADMIN][TRPC][DELETE_ORG]: non-admin authenticated user is rejected with 401', async ({ page }) => { + const { user: nonAdminUser } = await seedUser({ isAdmin: false }); + const { organisation } = await seedUser({ isPersonalOrganisation: false }); + + await apiSignin({ page, email: nonAdminUser.email }); + + const res = await callDeleteOrganisation(page, { + organisationId: organisation.id, + organisationName: organisation.name, + sendEmailToOwner: true, + }); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(401); + + const stillExists = await prisma.organisation.findUnique({ where: { id: organisation.id } }); + expect(stillExists).not.toBeNull(); + + const job = await prisma.backgroundJob.findFirst({ + where: { + jobId: 'internal.admin-delete-organisation', + payload: { path: ['organisationId'], equals: organisation.id }, + }, + }); + expect(job).toBeNull(); +}); + +test('[ADMIN][TRPC][DELETE_ORG]: organisation owner (non-admin) cannot delete their own org via admin route', async ({ + page, +}) => { + // Owners can delete via the regular organisation.delete endpoint, but the + // ADMIN endpoint must reject them too. + const { user: owner, organisation } = await seedUser({ isPersonalOrganisation: false }); + + await apiSignin({ page, email: owner.email }); + + const res = await callDeleteOrganisation(page, { + organisationId: organisation.id, + organisationName: organisation.name, + sendEmailToOwner: true, + }); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(401); + + const stillExists = await prisma.organisation.findUnique({ where: { id: organisation.id } }); + expect(stillExists).not.toBeNull(); +}); + +// ─── Validation ────────────────────────────────────────────────────────────── + +test('[ADMIN][TRPC][DELETE_ORG]: admin call with mismatched name is rejected and org is preserved', async ({ + page, +}) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + const { organisation } = await seedUser({ isPersonalOrganisation: false }); + + await apiSignin({ page, email: adminUser.email }); + + const res = await callDeleteOrganisation(page, { + organisationId: organisation.id, + organisationName: `${organisation.name}-WRONG`, + sendEmailToOwner: true, + }); + + expect(res.ok()).toBeFalsy(); + + // Body should contain INVALID_REQUEST error. + const body = await res.text(); + expect(body).toContain('does not match'); + + const stillExists = await prisma.organisation.findUnique({ where: { id: organisation.id } }); + expect(stillExists).not.toBeNull(); + + // Most importantly: no job has been enqueued for this org. + const job = await prisma.backgroundJob.findFirst({ + where: { + jobId: 'internal.admin-delete-organisation', + payload: { path: ['organisationId'], equals: organisation.id }, + }, + }); + expect(job).toBeNull(); +}); + +test('[ADMIN][TRPC][DELETE_ORG]: admin call against non-existent organisation returns NOT_FOUND', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + + await apiSignin({ page, email: adminUser.email }); + + const res = await callDeleteOrganisation(page, { + organisationId: 'org_does-not-exist-1234567890', + organisationName: 'Anything', + sendEmailToOwner: true, + }); + + expect(res.ok()).toBeFalsy(); + + const body = await res.text(); + expect(body).toContain('Organisation not found'); +}); + +test('[ADMIN][TRPC][DELETE_ORG]: zod schema rejects malformed input', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + + await apiSignin({ page, email: adminUser.email }); + + // Missing organisationName and sendEmailToOwner. + const res = await page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/admin.organisation.delete`, { + headers: { 'content-type': 'application/json' }, + data: JSON.stringify({ json: { organisationId: 'whatever' } }), + }); + + expect(res.ok()).toBeFalsy(); + // Zod validation failures surface as 400 from tRPC. + expect([400, 422]).toContain(res.status()); +}); + +// ─── Happy path via tRPC (admin) ──────────────────────────────────────────── + +test('[ADMIN][TRPC][DELETE_ORG]: admin can delete via the tRPC endpoint directly', async ({ page }) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + const { organisation } = await seedUser({ isPersonalOrganisation: false }); + + await apiSignin({ page, email: adminUser.email }); + + const res = await callDeleteOrganisation(page, { + organisationId: organisation.id, + organisationName: organisation.name, + sendEmailToOwner: false, + }); + + expect(res.ok()).toBeTruthy(); + + // Background job should be enqueued; wait for it to complete then verify + // the org is gone. + await expect + .poll( + async () => { + const job = await prisma.backgroundJob.findFirst({ + where: { + jobId: 'internal.admin-delete-organisation', + payload: { path: ['organisationId'], equals: organisation.id }, + }, + }); + + return job?.status ?? null; + }, + { timeout: 30_000, intervals: [250, 500, 1000] }, + ) + .toBe('COMPLETED'); + + const org = await prisma.organisation.findUnique({ where: { id: organisation.id } }); + expect(org).toBeNull(); +}); + +// ─── Idempotency: calling delete twice does not throw ─────────────────────── + +test('[ADMIN][TRPC][DELETE_ORG]: a second delete call after deletion is harmless (NOT_FOUND or no-op)', async ({ + page, +}) => { + const { user: adminUser } = await seedUser({ isAdmin: true }); + const { organisation } = await seedUser({ isPersonalOrganisation: false }); + + await apiSignin({ page, email: adminUser.email }); + + // First call succeeds. + const first = await callDeleteOrganisation(page, { + organisationId: organisation.id, + organisationName: organisation.name, + sendEmailToOwner: false, + }); + expect(first.ok()).toBeTruthy(); + + // Wait for the deletion to actually happen. + await expect + .poll( + async () => { + const org = await prisma.organisation.findUnique({ where: { id: organisation.id } }); + return org === null; + }, + { timeout: 30_000, intervals: [250, 500, 1000] }, + ) + .toBe(true); + + // Second call: the org no longer exists, so the route should fail with + // NOT_FOUND. It must NOT 500. + const second = await callDeleteOrganisation(page, { + organisationId: organisation.id, + organisationName: organisation.name, + sendEmailToOwner: false, + }); + expect(second.ok()).toBeFalsy(); + expect(second.status()).not.toBe(500); + + const body = await second.text(); + expect(body).toContain('Organisation not found'); +}); diff --git a/packages/app-tests/playwright.config.ts b/packages/app-tests/playwright.config.ts index eba5f33f1..2d7bfd901 100644 --- a/packages/app-tests/playwright.config.ts +++ b/packages/app-tests/playwright.config.ts @@ -41,7 +41,7 @@ export default defineConfig({ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { /* Base URL to use in actions like `await page.goto('/')`. */ - baseURL: 'http://localhost:3000', + baseURL: process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000', /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: 'retain-on-failure', diff --git a/packages/email/templates/organisation-delete.tsx b/packages/email/templates/organisation-delete.tsx new file mode 100644 index 000000000..2c560e6f4 --- /dev/null +++ b/packages/email/templates/organisation-delete.tsx @@ -0,0 +1,75 @@ +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; + +import { Body, Container, Head, Hr, Html, Img, Preview, Section, Text } from '../components'; +import { useBranding } from '../providers/branding'; +import { TemplateFooter } from '../template-components/template-footer'; +import TemplateImage from '../template-components/template-image'; + +export type OrganisationDeleteEmailProps = { + assetBaseUrl: string; + organisationName: string; + /** + * Whether the deletion was performed by an administrator (as opposed to the owner). + * Slightly changes the wording in the email. + */ + deletedByAdmin?: boolean; +}; + +export const OrganisationDeleteEmailTemplate = ({ + assetBaseUrl = 'http://localhost:3002', + organisationName = 'Organisation Name Placeholder', + deletedByAdmin = false, +}: OrganisationDeleteEmailProps) => { + const { _ } = useLingui(); + const branding = useBranding(); + + const previewText = msg`Your organisation has been deleted`; + + const title = msg`Your organisation has been deleted`; + + const description = deletedByAdmin + ? msg`The following organisation has been deleted by an administrator. You and your members will no longer be able to access this organisation, its teams, or its associated data.` + : msg`The following organisation has been deleted. You and your members will no longer be able to access this organisation, its teams, or its associated data.`; + + return ( + + + {_(previewText)} + + +
+ + {branding.brandingEnabled && branding.brandingLogo ? ( + Branding Logo + ) : ( + + )} + +
+ +
+ +
+ {_(title)} + + {_(description)} + +
+ {organisationName} +
+
+
+ +
+ + + + +
+ + + ); +}; + +export default OrganisationDeleteEmailTemplate; diff --git a/packages/lib/jobs/client.ts b/packages/lib/jobs/client.ts index d0b2c4f98..b2863dcc5 100644 --- a/packages/lib/jobs/client.ts +++ b/packages/lib/jobs/client.ts @@ -10,8 +10,10 @@ import { SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION } from './definitions/emails import { SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION } from './definitions/emails/send-rejection-emails'; import { SEND_SIGNING_EMAIL_JOB_DEFINITION } from './definitions/emails/send-signing-email'; import { SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-deleted-email'; +import { ADMIN_DELETE_ORGANISATION_JOB_DEFINITION } from './definitions/internal/admin-delete-organisation'; import { BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION } from './definitions/internal/backport-subscription-claims'; import { BULK_SEND_TEMPLATE_JOB_DEFINITION } from './definitions/internal/bulk-send-template'; +import { CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION } from './definitions/internal/cancel-organisation-subscription'; import { CLEANUP_RATE_LIMITS_JOB_DEFINITION } from './definitions/internal/cleanup-rate-limits'; import { EXECUTE_WEBHOOK_JOB_DEFINITION } from './definitions/internal/execute-webhook'; import { EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION } from './definitions/internal/expire-recipients-sweep'; @@ -49,6 +51,8 @@ export const jobsClient = new JobClient([ PROCESS_SIGNING_REMINDER_JOB_DEFINITION, CLEANUP_RATE_LIMITS_JOB_DEFINITION, SYNC_EMAIL_DOMAINS_JOB_DEFINITION, + ADMIN_DELETE_ORGANISATION_JOB_DEFINITION, + CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION, ] as const); export const jobs = jobsClient; diff --git a/packages/lib/jobs/definitions/internal/admin-delete-organisation.handler.ts b/packages/lib/jobs/definitions/internal/admin-delete-organisation.handler.ts new file mode 100644 index 000000000..544861697 --- /dev/null +++ b/packages/lib/jobs/definitions/internal/admin-delete-organisation.handler.ts @@ -0,0 +1,113 @@ +import { prisma } from '@documenso/prisma'; + +import { ORGANISATION_USER_ACCOUNT_TYPE } from '../../../constants/organisations'; +import { getEmailContext } from '../../../server-only/email/get-email-context'; +import { orphanEnvelopes } from '../../../server-only/envelope/orphan-envelopes'; +import { sendOrganisationDeleteEmail } from '../../../server-only/organisation/delete-organisation-email'; +import { jobs } from '../../client'; +import type { JobRunIO } from '../../client/_internal/job'; +import type { TAdminDeleteOrganisationJobDefinition } from './admin-delete-organisation'; + +export const run = async ({ payload, io }: { payload: TAdminDeleteOrganisationJobDefinition; io: JobRunIO }) => { + const { organisationId, sendEmailToOwner, requestedByUserId } = payload; + + // Get/store the organisation in a task so it can be accessed by subsequent tasks. + const organisation = await io.runTask('get-organisation', async () => { + io.logger.info(`User ${requestedByUserId} is deleting organisation ${organisationId}`); + + return await prisma.organisation.findUnique({ + where: { + id: organisationId, + }, + select: { + id: true, + name: true, + owner: { + select: { + id: true, + email: true, + name: true, + }, + }, + teams: { + select: { + id: true, + }, + }, + subscription: { + select: { + planId: true, + }, + }, + }, + }); + }); + + if (!organisation) { + // The organisation may have already been deleted by a prior run / another + // pathway. Treat as a no-op so the job doesn't retry forever. + return; + } + + const ownerEmail = organisation.owner.email; + + const emailContext = await io.runTask('get-email-context', async () => { + return await getEmailContext({ + emailType: 'INTERNAL', + source: { + type: 'organisation', + organisationId: organisation.id, + }, + }); + }); + + // 1. Orphan all envelopes for every team. + for (const team of organisation.teams) { + await io.runTask(`orphan-envelopes--team-${team.id}`, async () => { + await orphanEnvelopes({ teamId: team.id }); + }); + } + + // 2. Delete the organisation. Matches the transaction in organisation-router/delete-organisation.ts. + await io.runTask('delete-organisation', async () => { + await prisma.$transaction(async (tx) => { + await tx.account.deleteMany({ + where: { + type: ORGANISATION_USER_ACCOUNT_TYPE, + provider: organisation.id, + }, + }); + + await tx.organisation.delete({ + where: { + id: organisation.id, + }, + }); + }); + }); + + // 3. Send the owner notification. + if (sendEmailToOwner) { + await io.runTask('send-organisation-deleted-email', async () => { + await sendOrganisationDeleteEmail({ + email: ownerEmail, + organisationName: organisation.name, + deletedByAdmin: true, + emailContext, + }); + }); + } + + // 4. If the organisation has a Stripe subscription, schedule it to be cancelled at the end of the current billing period. + if (organisation.subscription) { + const stripeSubscriptionId = organisation.subscription.planId; + + await jobs.triggerJob({ + name: 'internal.cancel-organisation-subscription', + payload: { + stripeSubscriptionId, + organisationId: organisation.id, + }, + }); + } +}; diff --git a/packages/lib/jobs/definitions/internal/admin-delete-organisation.ts b/packages/lib/jobs/definitions/internal/admin-delete-organisation.ts new file mode 100644 index 000000000..d6c77f5e7 --- /dev/null +++ b/packages/lib/jobs/definitions/internal/admin-delete-organisation.ts @@ -0,0 +1,37 @@ +import { z } from 'zod'; + +import type { JobDefinition } from '../../client/_internal/job'; + +const ADMIN_DELETE_ORGANISATION_JOB_DEFINITION_ID = 'internal.admin-delete-organisation'; + +const ADMIN_DELETE_ORGANISATION_JOB_DEFINITION_SCHEMA = z.object({ + organisationId: z.string(), + /** + * Whether to email the organisation owner notifying them of the deletion. + */ + sendEmailToOwner: z.boolean(), + /** + * The id of the admin user who requested the deletion (for audit/logging). + */ + requestedByUserId: z.number(), +}); + +export type TAdminDeleteOrganisationJobDefinition = z.infer; + +export const ADMIN_DELETE_ORGANISATION_JOB_DEFINITION = { + id: ADMIN_DELETE_ORGANISATION_JOB_DEFINITION_ID, + name: 'Admin Delete Organisation', + version: '1.0.0', + trigger: { + name: ADMIN_DELETE_ORGANISATION_JOB_DEFINITION_ID, + schema: ADMIN_DELETE_ORGANISATION_JOB_DEFINITION_SCHEMA, + }, + handler: async ({ payload, io }) => { + const handler = await import('./admin-delete-organisation.handler'); + + await handler.run({ payload, io }); + }, +} as const satisfies JobDefinition< + typeof ADMIN_DELETE_ORGANISATION_JOB_DEFINITION_ID, + TAdminDeleteOrganisationJobDefinition +>; diff --git a/packages/lib/jobs/definitions/internal/cancel-organisation-subscription.handler.ts b/packages/lib/jobs/definitions/internal/cancel-organisation-subscription.handler.ts new file mode 100644 index 000000000..918fd70e6 --- /dev/null +++ b/packages/lib/jobs/definitions/internal/cancel-organisation-subscription.handler.ts @@ -0,0 +1,38 @@ +import { Stripe, stripe } from '../../../server-only/stripe'; +import type { JobRunIO } from '../../client/_internal/job'; +import type { TCancelOrganisationSubscriptionJobDefinition } from './cancel-organisation-subscription'; + +/** + * Marks the given Stripe subscription for cancellation at the end of the + * current billing period. + * + * Idempotent: calling this on an already-cancel-at-period-end subscription is + * a no-op for Stripe and returns the same shape, so re-running the job after + * a partial failure is safe. + * + * If the subscription no longer exists in Stripe (`resource_missing`), the + * job treats it as a no-op rather than retrying forever \u2014 nothing further + * can be done. + */ +export const run = async ({ payload }: { payload: TCancelOrganisationSubscriptionJobDefinition; io: JobRunIO }) => { + const { stripeSubscriptionId } = payload; + + try { + await stripe.subscriptions.update(stripeSubscriptionId, { + cancel_at_period_end: true, + }); + } catch (error) { + // Subscription no longer exists in Stripe \u2014 nothing to cancel. Treat as + // success so the job doesn't retry indefinitely. + if (error instanceof Stripe.errors.StripeInvalidRequestError && error.code === 'resource_missing') { + console.warn( + `[CANCEL_ORGANISATION_SUBSCRIPTION] Stripe subscription ${stripeSubscriptionId} no longer exists; skipping.`, + ); + + return; + } + + // Anything else: rethrow so the job runner retries. + throw error; + } +}; diff --git a/packages/lib/jobs/definitions/internal/cancel-organisation-subscription.ts b/packages/lib/jobs/definitions/internal/cancel-organisation-subscription.ts new file mode 100644 index 000000000..38ca07e49 --- /dev/null +++ b/packages/lib/jobs/definitions/internal/cancel-organisation-subscription.ts @@ -0,0 +1,43 @@ +import { z } from 'zod'; + +import type { JobDefinition } from '../../client/_internal/job'; + +const CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION_ID = 'internal.cancel-organisation-subscription'; + +const CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION_SCHEMA = z.object({ + /** + * The Stripe subscription id (Subscription.planId in our schema). + * + * This must be captured before the local organisation row is deleted, + * because the Subscription row cascades away when the organisation is + * removed. + */ + stripeSubscriptionId: z.string(), + /** + * The organisation id, for logging only. The organisation may no longer + * exist by the time this job runs. + */ + organisationId: z.string(), +}); + +export type TCancelOrganisationSubscriptionJobDefinition = z.infer< + typeof CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION_SCHEMA +>; + +export const CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION = { + id: CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION_ID, + name: 'Cancel Organisation Subscription', + version: '1.0.0', + trigger: { + name: CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION_ID, + schema: CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION_SCHEMA, + }, + handler: async ({ payload, io }) => { + const handler = await import('./cancel-organisation-subscription.handler'); + + await handler.run({ payload, io }); + }, +} as const satisfies JobDefinition< + typeof CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION_ID, + TCancelOrganisationSubscriptionJobDefinition +>; diff --git a/packages/lib/server-only/email/get-email-context.ts b/packages/lib/server-only/email/get-email-context.ts index 39313a8a0..b86746226 100644 --- a/packages/lib/server-only/email/get-email-context.ts +++ b/packages/lib/server-only/email/get-email-context.ts @@ -61,7 +61,7 @@ type RecipientGetEmailContextOptions = BaseGetEmailContextOptions & { type GetEmailContextOptions = InternalGetEmailContextOptions | RecipientGetEmailContextOptions; -type EmailContextResponse = { +export type EmailContextResponse = { allowedEmails: OrganisationEmail[]; branding: BrandingSettings; settings: Omit; diff --git a/packages/lib/server-only/organisation/delete-organisation-email.ts b/packages/lib/server-only/organisation/delete-organisation-email.ts new file mode 100644 index 000000000..f4cd7baec --- /dev/null +++ b/packages/lib/server-only/organisation/delete-organisation-email.ts @@ -0,0 +1,49 @@ +import { mailer } from '@documenso/email/mailer'; +import { OrganisationDeleteEmailTemplate } from '@documenso/email/templates/organisation-delete'; +import { msg } from '@lingui/core/macro'; +import { createElement } from 'react'; + +import { getI18nInstance } from '../../client-only/providers/i18n-server'; +import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; +import { renderEmailWithI18N } from '../../utils/render-email-with-i18n'; +import type { EmailContextResponse } from '../email/get-email-context'; + +export type SendOrganisationDeleteEmailOptions = { + email: string; + organisationName: string; + deletedByAdmin?: boolean; + emailContext: EmailContextResponse; +}; + +/** + * Sends an "organisation deleted" notification email. + */ +export const sendOrganisationDeleteEmail = async ({ + email, + organisationName, + deletedByAdmin = false, + emailContext, +}: SendOrganisationDeleteEmailOptions) => { + const template = createElement(OrganisationDeleteEmailTemplate, { + assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(), + organisationName, + deletedByAdmin, + }); + + const { branding, emailLanguage, senderEmail } = emailContext; + + const [html, text] = await Promise.all([ + renderEmailWithI18N(template, { lang: emailLanguage, branding }), + renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }), + ]); + + const i18n = await getI18nInstance(emailLanguage); + + await mailer.sendMail({ + to: email, + from: senderEmail, + subject: i18n._(msg`Organisation "${organisationName}" has been deleted`), + html, + text, + }); +}; diff --git a/packages/trpc/server/admin-router/delete-organisation.ts b/packages/trpc/server/admin-router/delete-organisation.ts new file mode 100644 index 000000000..cf877bcf7 --- /dev/null +++ b/packages/trpc/server/admin-router/delete-organisation.ts @@ -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, + }, + }); + }); diff --git a/packages/trpc/server/admin-router/delete-organisation.types.ts b/packages/trpc/server/admin-router/delete-organisation.types.ts new file mode 100644 index 000000000..e2c14c29f --- /dev/null +++ b/packages/trpc/server/admin-router/delete-organisation.types.ts @@ -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; +export type TDeleteOrganisationResponse = z.infer; diff --git a/packages/trpc/server/admin-router/router.ts b/packages/trpc/server/admin-router/router.ts index c36ef55bb..7efc6de70 100644 --- a/packages/trpc/server/admin-router/router.ts +++ b/packages/trpc/server/admin-router/router.ts @@ -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: { diff --git a/packages/trpc/server/organisation-router/delete-organisation.ts b/packages/trpc/server/organisation-router/delete-organisation.ts index 4a048e9b3..11b051ba3 100644 --- a/packages/trpc/server/organisation-router/delete-organisation.ts +++ b/packages/trpc/server/organisation-router/delete-organisation.ts @@ -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, + }, + }); + } });