diff --git a/apps/docs/content/docs/users/settings/delete-account.mdx b/apps/docs/content/docs/users/settings/delete-account.mdx index 569dc1bc2..77640db70 100644 --- a/apps/docs/content/docs/users/settings/delete-account.mdx +++ b/apps/docs/content/docs/users/settings/delete-account.mdx @@ -7,14 +7,14 @@ import { Callout } from 'fumadocs-ui/components/callout'; import { Step, Steps } from 'fumadocs-ui/components/steps'; - Account deletion is permanent and irreversible. All documents, signatures, templates, and account - data will be permanently removed. Any active subscription will be cancelled. + Account deletion is permanent and irreversible. Your account, signatures, and personal data will be + permanently removed, and any active subscription will be cancelled. How your organisations and + documents are handled is explained below. ## Before Deleting - Download any documents you need to keep -- Cancel any active subscriptions - Disable two-factor authentication (required before deletion) ## Delete Your Account @@ -36,6 +36,31 @@ import { Step, Steps } from 'fumadocs-ui/components/steps'; If you have two-factor authentication enabled, you must disable it before deleting your account. +## What Happens to Your Organisations + +When you delete your account, the organisations you **own** are permanently deleted along with all of +their teams. If an owned organisation has an active subscription, it is scheduled for cancellation at +the end of the current billing period. + +Organisations that you are only a **member** of are not deleted. You are simply removed from them, and +the organisation continues to operate as normal. + +## What Happens to Your Documents + +The way your documents and templates are handled depends on whether you owned the organisation they +belong to: + +- **Organisations you owned** — Completed and in-progress documents are retained in an anonymized form + (reassigned to an internal system account) so the other parties keep their records. Draft documents + and templates are permanently removed. +- **Organisations you were a member of** — Your documents and templates are transferred to the + organisation owner, so they remain accessible to the organisation after you leave. + + + Documents that are retained in anonymized form are no longer associated with your account and cannot + be recovered or accessed by you after deletion. Download anything you need to keep beforehand. + + --- ## See Also diff --git a/packages/app-tests/e2e/user/delete-account.spec.ts b/packages/app-tests/e2e/user/delete-account.spec.ts index 821cdc0ac..25888cfdc 100644 --- a/packages/app-tests/e2e/user/delete-account.spec.ts +++ b/packages/app-tests/e2e/user/delete-account.spec.ts @@ -1,23 +1,395 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { ORGANISATION_USER_ACCOUNT_TYPE } from '@documenso/lib/constants/organisations'; import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email'; +import { nanoid } from '@documenso/lib/universal/id'; +import { prisma } from '@documenso/prisma'; +import type { User } from '@documenso/prisma/client'; +import { DocumentStatus, EnvelopeType, SubscriptionStatus } 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 type { Page } from '@playwright/test'; import { expect, test } from '@playwright/test'; import { apiSignin } from '../fixtures/authentication'; -test('[USER] delete account', async ({ page }) => { - const { user } = await seedUser(); +test.describe.configure({ mode: 'parallel' }); - await apiSignin({ page, email: user.email, redirectPath: '/settings' }); +const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL(); + +/** + * The deleted-account service account is where orphaned DOCUMENT envelopes land + * when the team/org they belong to is torn down. It is created by a migration so + * it always exists in the test database. + */ +const getDeletedServiceAccount = async () => { + const deletedAccount = await prisma.user.findFirstOrThrow({ + where: { email: { startsWith: 'deleted-account@' } }, + select: { + id: true, + ownedOrganisations: { select: { teams: { select: { id: true } } } }, + }, + }); + + return { + id: deletedAccount.id, + teamId: deletedAccount.ownedOrganisations[0].teams[0].id, + }; +}; + +/** + * Drives the account deletion through the settings UI, exactly as a user would. + * Returns once the app has redirected to the sign-in page (deletion is performed + * synchronously by the `profile.deleteAccount` mutation before the redirect). + */ +const deleteAccountViaUi = async (page: Page, email: string) => { + await apiSignin({ page, email, redirectPath: '/settings' }); await page.getByRole('button', { name: 'Delete Account' }).click(); - await page.getByLabel('Confirm Email').fill(user.email); + await page.getByLabel('Confirm Email').fill(email); await expect(page.getByRole('button', { name: 'Confirm Deletion' })).not.toBeDisabled(); await page.getByRole('button', { name: 'Confirm Deletion' }).click(); - await page.waitForURL(`${NEXT_PUBLIC_WEBAPP_URL()}/signin`); + await page.waitForURL(`${WEBAPP_BASE_URL}/signin`); +}; - // Verify that the user no longer exists in the database +const seedDocumentWithStatus = async (sender: User, teamId: number, key: string, status: DocumentStatus) => { + const document = await seedBlankDocument(sender, teamId, { key }); + + if (status !== DocumentStatus.DRAFT) { + await prisma.envelope.update({ + where: { id: document.id }, + data: { status }, + }); + } + + return document; +}; + +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 after account deletion`, + timeout: 15_000, + intervals: [250, 500, 1000], + }, + ) + .toBe(true); +}; + +// ─── Happy path: the basic flow still works ────────────────────────────────── + +test('[USER] delete account', async ({ page }) => { + const { user } = await seedUser(); + + await deleteAccountViaUi(page, user.email); + + // Verify that the user no longer exists in the database. await expect(getUserByEmail({ email: user.email })).rejects.toThrow(); }); + +// ─── Owned organisation: documents orphaned to the service account ─────────── + +test('[USER][DELETE_ACCOUNT]: owned org docs are orphaned to service account, drafts and templates removed', async ({ + page, +}) => { + const { user, organisation, team } = await seedUser(); + + // Inflight/completed DOCUMENT envelopes that must survive as orphans. + const completed = await seedDocumentWithStatus(user, team.id, 'owned-completed', DocumentStatus.COMPLETED); + const pending = await seedDocumentWithStatus(user, team.id, 'owned-pending', DocumentStatus.PENDING); + const rejected = await seedDocumentWithStatus(user, team.id, 'owned-rejected', DocumentStatus.REJECTED); + + // A draft DOCUMENT — orphan only re-parents PENDING/REJECTED/COMPLETED, so it is hard-deleted. + const draft = await seedDocumentWithStatus(user, team.id, 'owned-draft', DocumentStatus.DRAFT); + + // A TEMPLATE — orphan only re-parents DOCUMENT envelopes, so it is hard-deleted. + const template = await seedBlankDocument(user, team.id, { key: 'owned-template' }); + await prisma.envelope.update({ + where: { id: template.id }, + data: { type: EnvelopeType.TEMPLATE }, + }); + + expect(await prisma.envelope.count({ where: { teamId: team.id } })).toBe(5); + + await deleteAccountViaUi(page, user.email); + + await waitForOrganisationToBeGone(organisation.id); + + const service = await getDeletedServiceAccount(); + + // Completed/pending/rejected: re-parented to the service account + soft-deleted. + for (const original of [completed, pending, rejected]) { + const after = await prisma.envelope.findUnique({ + where: { id: original.id }, + select: { id: true, teamId: true, userId: true, deletedAt: true }, + }); + + expect(after, `envelope ${original.id} should survive as an orphan`).not.toBeNull(); + expect(after?.teamId).toBe(service.teamId); + expect(after?.userId).toBe(service.id); + expect(after?.deletedAt).not.toBeNull(); + } + + // Draft + template are hard-deleted. + expect(await prisma.envelope.findUnique({ where: { id: draft.id } })).toBeNull(); + expect(await prisma.envelope.findUnique({ where: { id: template.id } })).toBeNull(); + + // The owned org, its team, and the user are gone. Nothing references the old team. + expect(await prisma.organisation.findUnique({ where: { id: organisation.id } })).toBeNull(); + expect(await prisma.team.findUnique({ where: { id: team.id } })).toBeNull(); + expect(await prisma.user.findUnique({ where: { id: user.id } })).toBeNull(); + expect(await prisma.envelope.count({ where: { teamId: team.id } })).toBe(0); +}); + +// ─── Member of another org: documents transferred to the OWNER, not deleted ── + +test('[USER][DELETE_ACCOUNT]: docs in orgs the user is a member of are transferred to the org owner', async ({ + page, +}) => { + // Another org, owned by someone else, that the deleted user is merely a member of. + const { user: ownerB, organisation: orgB, team: teamB } = await seedUser(); + + // The account being deleted. They own their own (personal) org too. + const { user: userA, organisation: orgA, team: teamA } = await seedUser(); + + await seedOrganisationMembers({ + organisationId: orgB.id, + members: [{ email: userA.email, name: userA.name ?? 'User A', organisationRole: 'MEMBER' }], + }); + + // userA authors envelopes inside orgB's team (both completed and draft). + const memberCompleted = await seedDocumentWithStatus(userA, teamB.id, 'member-completed', DocumentStatus.COMPLETED); + const memberDraft = await seedDocumentWithStatus(userA, teamB.id, 'member-draft', DocumentStatus.DRAFT); + + // userA also has a completed doc in their OWN org (should orphan to service account). + const ownedCompleted = await seedDocumentWithStatus(userA, teamA.id, 'owned-completed', DocumentStatus.COMPLETED); + + await deleteAccountViaUi(page, userA.email); + + await waitForOrganisationToBeGone(orgA.id); + + const service = await getDeletedServiceAccount(); + + // Member-org envelopes — regardless of status — are reassigned to orgB's owner, + // stay in orgB's team, and are NOT soft-deleted. + for (const original of [memberCompleted, memberDraft]) { + 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 be transferred, not deleted`).not.toBeNull(); + expect(after?.teamId).toBe(teamB.id); + expect(after?.userId).toBe(ownerB.id); + expect(after?.deletedAt).toBeNull(); + } + + // The other org and its owner survive — only the deleted user's own org is removed. + expect(await prisma.organisation.findUnique({ where: { id: orgB.id } })).not.toBeNull(); + expect(await prisma.user.findUnique({ where: { id: ownerB.id } })).not.toBeNull(); + + // The deleted user's own completed doc was orphaned to the service account. + const ownedAfter = await prisma.envelope.findUnique({ + where: { id: ownedCompleted.id }, + select: { teamId: true, userId: true, deletedAt: true }, + }); + expect(ownedAfter, 'owned-org envelope should survive as an orphan').not.toBeNull(); + expect(ownedAfter?.teamId).toBe(service.teamId); + expect(ownedAfter?.userId).toBe(service.id); + expect(ownedAfter?.deletedAt).not.toBeNull(); + + // userA is gone. + expect(await prisma.user.findUnique({ where: { id: userA.id } })).toBeNull(); +}); + +// ─── Owned org with members: org torn down, members survive, their docs orphaned ─ + +test('[USER][DELETE_ACCOUNT]: deleting the owner removes the org but keeps members and orphans their docs', async ({ + page, +}) => { + const { user: owner, organisation, team } = await seedUser(); + + const [member] = await seedOrganisationMembers({ + organisationId: organisation.id, + members: [{ organisationRole: 'MEMBER' }], + }); + + // A member (not the owner) authored a completed doc inside the owned org's team. + // The orphan logic filters by teamId only, so this must be orphaned too. + const memberCompleted = await seedDocumentWithStatus(member, team.id, 'member-completed', DocumentStatus.COMPLETED); + + await deleteAccountViaUi(page, owner.email); + + await waitForOrganisationToBeGone(organisation.id); + + const service = await getDeletedServiceAccount(); + + const after = await prisma.envelope.findUnique({ + where: { id: memberCompleted.id }, + select: { teamId: true, userId: true, deletedAt: true }, + }); + expect(after, 'member-authored envelope should survive as an orphan').not.toBeNull(); + expect(after?.teamId).toBe(service.teamId); + expect(after?.userId).toBe(service.id); + expect(after?.deletedAt).not.toBeNull(); + + // The member user survives — only the org and its owner are removed. + expect(await prisma.user.findUnique({ where: { id: member.id } })).not.toBeNull(); + expect(await prisma.organisation.findUnique({ where: { id: organisation.id } })).toBeNull(); + expect(await prisma.user.findUnique({ where: { id: owner.id } })).toBeNull(); +}); + +// ─── Subscription cancellation is scheduled for owned orgs ─────────────────── + +test('[USER][DELETE_ACCOUNT]: a cancel-subscription job is enqueued for an owned org that has a subscription', async ({ + page, +}) => { + const { user, organisation } = await seedUser(); + + const planId = `sub_e2e_${nanoid()}`; + + await prisma.subscription.create({ + data: { + status: SubscriptionStatus.ACTIVE, + planId, + priceId: `price_e2e_${nanoid()}`, + customerId: `cus_e2e_${nanoid()}`, + organisationId: organisation.id, + }, + }); + + await deleteAccountViaUi(page, user.email); + + await waitForOrganisationToBeGone(organisation.id); + + // The deletion must schedule the Stripe subscription cancellation job with the + // captured planId (the Subscription row itself cascades away with the org). + await expect + .poll( + async () => { + const job = await prisma.backgroundJob.findFirst({ + where: { + jobId: 'internal.cancel-organisation-subscription', + payload: { path: ['organisationId'], equals: organisation.id }, + }, + }); + + if (!job) { + return null; + } + + return (job.payload as { stripeSubscriptionId?: string }).stripeSubscriptionId ?? null; + }, + { + message: 'cancel-organisation-subscription job was not enqueued', + timeout: 15_000, + intervals: [250, 500, 1000], + }, + ) + .toBe(planId); + + // The local Subscription row cascades away with the organisation — which is + // exactly why the planId has to be captured into the job payload beforehand. + expect(await prisma.subscription.findUnique({ where: { planId } })).toBeNull(); +}); + +// ─── Owned org account (SSO) rows are cleaned up, members survive ──────────── + +test('[USER][DELETE_ACCOUNT]: org-linked account rows are removed when an owned org is torn down', async ({ page }) => { + const { user: owner, organisation } = await seedUser(); + + const [member] = await seedOrganisationMembers({ + organisationId: organisation.id, + members: [{ organisationRole: 'MEMBER' }], + }); + + // Simulate a member who linked their login through the organisation's SSO. + // These rows are keyed by `provider = organisation.id` and have no foreign key + // to the organisation, so they must be deleted explicitly during teardown. + const orgAccount = await prisma.account.create({ + data: { + userId: member.id, + type: ORGANISATION_USER_ACCOUNT_TYPE, + provider: organisation.id, + providerAccountId: `oidc-${nanoid()}`, + }, + }); + + await deleteAccountViaUi(page, owner.email); + + await waitForOrganisationToBeGone(organisation.id); + + // The org-linked account row is gone... + expect(await prisma.account.findUnique({ where: { id: orgAccount.id } })).toBeNull(); + expect( + await prisma.account.count({ + where: { type: ORGANISATION_USER_ACCOUNT_TYPE, provider: organisation.id }, + }), + ).toBe(0); + + // ...but the member user it belonged to survives (only the org + owner are removed). + expect(await prisma.user.findUnique({ where: { id: member.id } })).not.toBeNull(); + expect(await prisma.user.findUnique({ where: { id: owner.id } })).toBeNull(); +}); + +// ─── Sad path: no subscription means no cancel job is enqueued ──────────────── + +test('[USER][DELETE_ACCOUNT]: no cancel-subscription job is enqueued when the owned org has no subscription', async ({ + page, +}) => { + const { user, organisation } = await seedUser(); + + await deleteAccountViaUi(page, user.email); + + await waitForOrganisationToBeGone(organisation.id); + + const job = await prisma.backgroundJob.findFirst({ + where: { + jobId: 'internal.cancel-organisation-subscription', + payload: { path: ['organisationId'], equals: organisation.id }, + }, + }); + + expect(job).toBeNull(); +}); + +// ─── Sad path: a mismatched confirmation email leaves everything intact ─────── + +test('[USER][DELETE_ACCOUNT]: a wrong confirmation email keeps the account, org and documents intact', async ({ + page, +}) => { + const { user, organisation, team } = await seedUser(); + + const completed = await seedDocumentWithStatus(user, team.id, 'kept-completed', DocumentStatus.COMPLETED); + + await apiSignin({ page, email: user.email, redirectPath: '/settings' }); + + await page.getByRole('button', { name: 'Delete Account' }).click(); + await page.getByLabel('Confirm Email').fill('not-my-email@example.com'); + + // The confirm button stays disabled while the email does not match. + await expect(page.getByRole('button', { name: 'Confirm Deletion' })).toBeDisabled(); + + // Nothing was deleted or orphaned. + expect(await prisma.user.findUnique({ where: { id: user.id } })).not.toBeNull(); + expect(await prisma.organisation.findUnique({ where: { id: organisation.id } })).not.toBeNull(); + + const docAfter = await prisma.envelope.findUnique({ + where: { id: completed.id }, + select: { teamId: true, userId: true, deletedAt: true }, + }); + expect(docAfter?.teamId).toBe(team.id); + expect(docAfter?.userId).toBe(user.id); + expect(docAfter?.deletedAt).toBeNull(); +}); diff --git a/packages/lib/jobs/definitions/internal/admin-delete-organisation.handler.ts b/packages/lib/jobs/definitions/internal/admin-delete-organisation.handler.ts index 3de61331c..433eda723 100644 --- a/packages/lib/jobs/definitions/internal/admin-delete-organisation.handler.ts +++ b/packages/lib/jobs/definitions/internal/admin-delete-organisation.handler.ts @@ -1,10 +1,8 @@ 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 { deleteOrganisation } from '../../../server-only/organisation/delete-organisation'; 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'; @@ -63,32 +61,13 @@ export const run = async ({ payload, io }: { payload: TAdminDeleteOrganisationJo return serializableContext; }); - // 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. + // 1. Orphan envelopes, delete the organisation, and schedule the Stripe + // subscription cancellation. Shared with 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, - }, - }); - }); + await deleteOrganisation({ organisation }); }); - // 3. Send the owner notification. + // 2. Send the owner notification. if (sendEmailToOwner) { await io.runTask('send-organisation-deleted-email', async () => { await sendOrganisationDeleteEmail({ @@ -99,17 +78,4 @@ export const run = async ({ payload, io }: { payload: TAdminDeleteOrganisationJo }); }); } - - // 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/server-only/organisation/delete-organisation.ts b/packages/lib/server-only/organisation/delete-organisation.ts new file mode 100644 index 000000000..e0c8bded6 --- /dev/null +++ b/packages/lib/server-only/organisation/delete-organisation.ts @@ -0,0 +1,55 @@ +import { prisma } from '@documenso/prisma'; + +import { ORGANISATION_USER_ACCOUNT_TYPE } from '../../constants/organisations'; +import { jobs } from '../../jobs/client'; +import { orphanEnvelopes } from '../envelope/orphan-envelopes'; + +export type DeleteOrganisationOptions = { + organisation: { + id: string; + teams: { id: number }[]; + subscription: { planId: string } | null; + }; +}; + +/** + * Fully tears down an organisation: + * + * 1. Orphans every team's envelopes (so foreign key constraints don't block the delete). + * 2. Removes the organisation's account rows and the organisation itself in a transaction. + * 3. Schedules the Stripe subscription to be cancelled at the end of the billing period + * (when one exists). The job runs asynchronously so a Stripe outage doesn't block the + * delete, and is retried by the job runner if Stripe is temporarily unavailable. + * + * Authorization must be handled by the caller. This is the shared implementation used by + * the organisation delete route, the admin delete-organisation job, and account deletion. + */ +export const deleteOrganisation = async ({ organisation }: DeleteOrganisationOptions) => { + // 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: { + type: ORGANISATION_USER_ACCOUNT_TYPE, + provider: organisation.id, + }, + }); + + await tx.organisation.delete({ + where: { + id: organisation.id, + }, + }); + }); + + if (organisation.subscription) { + await jobs.triggerJob({ + name: 'internal.cancel-organisation-subscription', + payload: { + stripeSubscriptionId: organisation.subscription.planId, + organisationId: organisation.id, + }, + }); + } +}; diff --git a/packages/lib/server-only/user/delete-user.ts b/packages/lib/server-only/user/delete-user.ts index 6a4da06a2..2ad28d42c 100644 --- a/packages/lib/server-only/user/delete-user.ts +++ b/packages/lib/server-only/user/delete-user.ts @@ -1,7 +1,7 @@ import { prisma } from '@documenso/prisma'; import { AppError, AppErrorCode } from '../../errors/app-error'; -import { orphanEnvelopes } from '../envelope/orphan-envelopes'; +import { deleteOrganisation } from '../organisation/delete-organisation'; export type DeleteUserOptions = { id: number; @@ -20,6 +20,11 @@ export const deleteUser = async ({ id }: DeleteUserOptions) => { id: true, }, }, + subscription: { + select: { + planId: true, + }, + }, }, }, organisationMember: { @@ -44,9 +49,6 @@ export const deleteUser = async ({ id }: DeleteUserOptions) => { }); } - // Get team IDs from organisations the user owns. - const ownedTeamIds = user.ownedOrganisations.flatMap((org) => org.teams.map((team) => team.id)); - // 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) @@ -57,8 +59,13 @@ export const deleteUser = async ({ id }: DeleteUserOptions) => { })), ); - // For teams where user is the org owner - orphan their envelopes. - await Promise.all(ownedTeamIds.map(async (teamId) => orphanEnvelopes({ teamId }))); + // For organisations the user owns - fully tear them down (orphan envelopes, + // delete the organisation, and cancel any Stripe subscription). Without this + // the organisations would only cascade away when the user row is deleted, + // leaving their subscriptions billing and account rows behind. + for (const organisation of user.ownedOrganisations) { + await deleteOrganisation({ organisation }); + } // For teams where user is a member (not owner) - transfer envelopes to team owner. await Promise.all( diff --git a/packages/trpc/server/organisation-router/delete-organisation.ts b/packages/trpc/server/organisation-router/delete-organisation.ts index 11b051ba3..f622fe643 100644 --- a/packages/trpc/server/organisation-router/delete-organisation.ts +++ b/packages/trpc/server/organisation-router/delete-organisation.ts @@ -1,10 +1,6 @@ -import { - ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP, - ORGANISATION_USER_ACCOUNT_TYPE, -} from '@documenso/lib/constants/organisations'; +import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } 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 { deleteOrganisation } from '@documenso/lib/server-only/organisation/delete-organisation'; import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations'; import { prisma } from '@documenso/prisma'; @@ -57,35 +53,5 @@ 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: { - type: ORGANISATION_USER_ACCOUNT_TYPE, - provider: organisation.id, - }, - }); - - await tx.organisation.delete({ - where: { - id: organisation.id, - }, - }); - }); - - // 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, - }, - }); - } + await deleteOrganisation({ organisation }); }); diff --git a/packages/trpc/server/profile-router/router.ts b/packages/trpc/server/profile-router/router.ts index 750f0c60e..f75610609 100644 --- a/packages/trpc/server/profile-router/router.ts +++ b/packages/trpc/server/profile-router/router.ts @@ -36,6 +36,12 @@ export const profileRouter = router({ }), deleteAccount: authenticatedProcedure.mutation(async ({ ctx }) => { + ctx.logger.info({ + input: { + userId: ctx.user.id, + }, + }); + await deleteUser({ id: ctx.user.id, });