Merge remote-tracking branch 'origin/main' into pr-2508

# Conflicts:
#	packages/lib/translations/de/web.po
#	packages/lib/translations/en/web.po
#	packages/lib/translations/es/web.po
#	packages/lib/translations/fr/web.po
#	packages/lib/translations/it/web.po
#	packages/lib/translations/ja/web.po
#	packages/lib/translations/ko/web.po
#	packages/lib/translations/nl/web.po
#	packages/lib/translations/pl/web.po
#	packages/lib/translations/pt-BR/web.po
#	packages/lib/translations/zh/web.po
This commit is contained in:
ephraimduncan
2026-05-14 15:44:01 +00:00
88 changed files with 20348 additions and 2611 deletions
@@ -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();
});
@@ -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');
});
+1 -1
View File
@@ -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',
Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 MiB

After

Width:  |  Height:  |  Size: 3.9 MiB

@@ -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 (
<Html>
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto font-sans">
<Section className="bg-white text-slate-500">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-2 backdrop-blur-sm">
{branding.brandingEnabled && branding.brandingLogo ? (
<Img src={branding.brandingLogo} alt="Branding Logo" className="mb-4 h-6 p-2" />
) : (
<TemplateImage assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" staticAsset="logo.png" />
)}
<Section>
<TemplateImage className="mx-auto" assetBaseUrl={assetBaseUrl} staticAsset="delete-team.png" />
</Section>
<Section className="p-2 text-slate-500">
<Text className="text-center font-medium text-black text-lg">{_(title)}</Text>
<Text className="my-1 text-center text-base">{_(description)}</Text>
<div className="mx-auto my-2 w-fit rounded-lg bg-gray-50 px-4 py-2 font-medium text-base text-slate-600">
{organisationName}
</div>
</Section>
</Container>
<Hr className="mx-auto mt-12 max-w-xl" />
<Container className="mx-auto max-w-xl">
<TemplateFooter isDocument={false} />
</Container>
</Section>
</Body>
</Html>
);
};
export default OrganisationDeleteEmailTemplate;
@@ -0,0 +1,90 @@
import { env } from '@documenso/lib/utils/env';
export const DOCUMENT_CONVERSION_MIME_TYPE_DOCX =
'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
const DEFAULT_DOCUMENT_CONVERSION_TIMEOUT_MS = 30_000;
/**
* Returns whether the document conversion feature is enabled.
*
* Platform-aware:
* - On the server, checks the private URL is configured.
* - On the client, reads the derived public flag injected via `window.__ENV__`.
*/
export const IS_DOCUMENT_CONVERSION_ENABLED = (): boolean => {
if (typeof window === 'undefined') {
return !!env('NEXT_PRIVATE_DOCUMENT_CONVERSION_URL');
}
return env('NEXT_PUBLIC_DOCUMENT_CONVERSION_ENABLED') === 'true';
};
/**
* Returns the configured conversion service base URL as supplied via env, or
* `undefined` if not configured.
*
* Server-side only.
*/
export const DOCUMENT_CONVERSION_URL = (): string | undefined => {
return env('NEXT_PRIVATE_DOCUMENT_CONVERSION_URL');
};
/**
* Returns HTTP Basic auth credentials for the conversion service, or
* `undefined` if either env var is missing. When Gotenberg is started with
* `--api-enable-basic-auth`, every request must carry these credentials.
*
* Server-side only.
*/
export const DOCUMENT_CONVERSION_AUTH = (): { username: string; password: string } | undefined => {
const username = env('NEXT_PRIVATE_DOCUMENT_CONVERSION_USERNAME');
const password = env('NEXT_PRIVATE_DOCUMENT_CONVERSION_PASSWORD');
if (!username || !password) {
return undefined;
}
return { username, password };
};
/**
* Returns the per-request timeout for conversion calls in milliseconds.
*
* Falls back to a 30 second default when the env value is missing or
* unparseable.
*/
export const DOCUMENT_CONVERSION_TIMEOUT_MS = (): number => {
const raw = env('NEXT_PRIVATE_DOCUMENT_CONVERSION_TIMEOUT_MS');
if (!raw) {
return DEFAULT_DOCUMENT_CONVERSION_TIMEOUT_MS;
}
const parsed = parseInt(raw, 10);
if (Number.isNaN(parsed) || parsed <= 0) {
return DEFAULT_DOCUMENT_CONVERSION_TIMEOUT_MS;
}
return parsed;
};
/**
* Returns the mime type -> extensions map that should be passed to the
* dropzone `accept` config and used for server-side validation.
*
* Always includes PDF; only includes DOCX when the conversion feature is
* enabled.
*/
export const getAllowedUploadMimeTypes = (): Record<string, string[]> => {
const base: Record<string, string[]> = {
'application/pdf': ['.pdf'],
};
if (IS_DOCUMENT_CONVERSION_ENABLED()) {
base[DOCUMENT_CONVERSION_MIME_TYPE_DOCX] = ['.docx'];
}
return base;
};
+4
View File
@@ -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;
@@ -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,
},
});
}
};
@@ -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<typeof ADMIN_DELETE_ORGANISATION_JOB_DEFINITION_SCHEMA>;
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
>;
@@ -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;
}
};
@@ -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
>;
@@ -0,0 +1,37 @@
/**
* In-process circuit breaker for the document conversion service.
*
* Behaviour: any failure opens the circuit for `COOLDOWN_MS`. While open,
* callers should fail fast without hitting the network. The first request
* after the cooldown is allowed through and either closes the circuit (on
* success) or re-opens it for another cooldown window (on failure).
*
* State is stored on `globalThis` so it survives Vite/Remix HMR in dev and
* is unambiguously process-wide. This module is intentionally pure and
* synchronous: no I/O, no logger import — callers handle observability.
*/
const COOLDOWN_MS = 30_000;
declare global {
// eslint-disable-next-line no-var
var __documensoConversionCircuitOpenedAt: number | null | undefined;
}
export const isCircuitOpen = (): boolean => {
const openedAt = globalThis.__documensoConversionCircuitOpenedAt;
if (!openedAt) {
return false;
}
return Date.now() - openedAt < COOLDOWN_MS;
};
export const recordSuccess = (): void => {
globalThis.__documensoConversionCircuitOpenedAt = null;
};
export const recordFailure = (): void => {
globalThis.__documensoConversionCircuitOpenedAt = Date.now();
};
@@ -0,0 +1,92 @@
import { AppError } from '@documenso/lib/errors/app-error';
import type { Logger } from 'pino';
import {
DOCUMENT_CONVERSION_MIME_TYPE_DOCX,
IS_DOCUMENT_CONVERSION_ENABLED,
} from '../../constants/document-conversion';
import { isCircuitOpen, recordFailure, recordSuccess } from './circuit-breaker';
import { convertDocxToPdfViaGotenberg } from './gotenberg';
type ConvertDocxToPdfOptions = {
buffer: Buffer;
filename: string;
};
const NOT_CONFIGURED_USER_MESSAGE = "Document conversion isn't enabled on this instance. Please upload a PDF.";
const UNAVAILABLE_USER_MESSAGE =
'Document conversion is temporarily unavailable. Please try again shortly or upload a PDF.';
/**
* Converts a DOCX buffer to a PDF buffer via the configured Gotenberg
* conversion service. Guards on feature-enabled and circuit-open state,
* and emits a structured log line for each attempt.
*/
export const convertDocxToPdf = async (
{ buffer, filename }: ConvertDocxToPdfOptions,
logger?: Logger,
): Promise<Buffer> => {
if (!IS_DOCUMENT_CONVERSION_ENABLED()) {
throw new AppError('CONVERSION_SERVICE_UNAVAILABLE', {
message: 'Conversion service not configured',
userMessage: NOT_CONFIGURED_USER_MESSAGE,
statusCode: 503,
});
}
if (isCircuitOpen()) {
throw new AppError('CONVERSION_SERVICE_UNAVAILABLE', {
message: 'Conversion circuit is open; failing fast',
userMessage: UNAVAILABLE_USER_MESSAGE,
statusCode: 503,
});
}
const startedAt = Date.now();
try {
const outputBuffer = await convertDocxToPdfViaGotenberg({ buffer, filename });
recordSuccess();
logger?.info({
event: 'document_conversion_attempt',
filename,
sourceMimeType: DOCUMENT_CONVERSION_MIME_TYPE_DOCX,
durationMs: Date.now() - startedAt,
inputBytes: buffer.byteLength,
outputBytes: outputBuffer.byteLength,
});
return outputBuffer;
} catch (err) {
recordFailure();
const errMessage = err instanceof Error ? err.message : String(err);
const errCode = err instanceof AppError ? err.code : 'UNKNOWN';
const logData = {
event: 'document_conversion_attempt',
filename,
sourceMimeType: DOCUMENT_CONVERSION_MIME_TYPE_DOCX,
durationMs: Date.now() - startedAt,
inputBytes: buffer.byteLength,
failed: true,
errorCode: errCode,
error: errMessage,
};
// A non-2xx from the conversion service surfaces as CONVERSION_FAILED.
// We log those at `error` level (status + truncated body live in the
// AppError message). All other failures stay at `info` to avoid noisy
// logs from transient network blips that the breaker already handles.
if (errCode === 'CONVERSION_FAILED') {
logger?.error(logData);
} else {
logger?.info(logData);
}
throw err;
}
};
@@ -0,0 +1,135 @@
import { AppError } from '@documenso/lib/errors/app-error';
import {
DOCUMENT_CONVERSION_AUTH,
DOCUMENT_CONVERSION_MIME_TYPE_DOCX,
DOCUMENT_CONVERSION_TIMEOUT_MS,
DOCUMENT_CONVERSION_URL,
} from '../../constants/document-conversion';
type ConvertDocxToPdfViaGotenbergOptions = {
buffer: Buffer;
filename: string;
};
const UNAVAILABLE_USER_MESSAGE =
'Document conversion is temporarily unavailable. Please try again shortly or upload a PDF.';
const NOT_CONFIGURED_USER_MESSAGE = "Document conversion isn't enabled on this instance. Please upload a PDF.";
const CONVERSION_FAILED_USER_MESSAGE =
"We couldn't convert this file. Please check it's a valid Word document or upload a PDF instead.";
const MAX_ERROR_BODY_CHARS = 500;
/**
* Posts a DOCX file to the configured Gotenberg-compatible conversion
* service and returns the resulting PDF bytes.
*
* Throws an `AppError` for all failure modes:
* - `CONVERSION_SERVICE_UNAVAILABLE` for missing config, timeout, or
* network errors.
* - `CONVERSION_FAILED` for non-2xx responses from the service.
*/
export const convertDocxToPdfViaGotenberg = async ({
buffer,
filename,
}: ConvertDocxToPdfViaGotenbergOptions): Promise<Buffer> => {
const url = DOCUMENT_CONVERSION_URL();
if (!url) {
throw new AppError('CONVERSION_SERVICE_UNAVAILABLE', {
message: 'Conversion service URL is not configured',
userMessage: NOT_CONFIGURED_USER_MESSAGE,
statusCode: 503,
});
}
const formData = new FormData();
const blob = new Blob([buffer], { type: DOCUMENT_CONVERSION_MIME_TYPE_DOCX });
formData.append('files', blob, filename);
// Tell LibreOffice NOT to export Word content controls (`<w:sdt>`) as PDF
// AcroForm fields. By default Gotenberg renders the field values into form
// appearance streams that reference unembedded base fonts (Times-Roman,
// Times-Bold). Our downstream `normalizePdf` flattens the form, but the
// pdf-lib flattening drops those appearance streams, so every SDT-bound
// string (i.e. virtually all of the body text in Office resume / cover-
// letter templates) ends up invisible in the final PDF. Disabling form
// export makes LibreOffice render those strings as regular text in the
// page content stream, with all glyphs embedded.
formData.append('exportFormFields', 'false');
// When the service is launched with `--api-enable-basic-auth`, every
// route (including `/health` and `/forms/libreoffice/convert`) requires
// HTTP Basic credentials. When auth env vars are not configured we send
// no header and rely on the service running without auth enabled.
const auth = DOCUMENT_CONVERSION_AUTH();
const headers: Record<string, string> = {};
if (auth) {
const encoded = Buffer.from(`${auth.username}:${auth.password}`).toString('base64');
headers.Authorization = `Basic ${encoded}`;
}
const controller = new AbortController();
const timeoutHandle = setTimeout(() => controller.abort(), DOCUMENT_CONVERSION_TIMEOUT_MS());
const convertEndpoint = new URL('/forms/libreoffice/convert', url).toString();
try {
const response = await fetch(convertEndpoint, {
method: 'POST',
body: formData,
headers,
signal: controller.signal,
});
if (!response.ok) {
let body = '';
try {
body = await response.text();
} catch {
body = '';
}
const truncatedBody = body.length > MAX_ERROR_BODY_CHARS ? `${body.slice(0, MAX_ERROR_BODY_CHARS)}...` : body;
throw new AppError('CONVERSION_FAILED', {
message: `Conversion service returned ${response.status}: ${truncatedBody}`,
userMessage: CONVERSION_FAILED_USER_MESSAGE,
statusCode: 400,
});
}
const arrayBuffer = await response.arrayBuffer();
return Buffer.from(arrayBuffer);
} catch (err) {
if (err instanceof AppError) {
throw err;
}
const isAbortError = err instanceof Error && err.name === 'AbortError';
if (isAbortError) {
throw new AppError('CONVERSION_SERVICE_UNAVAILABLE', {
message: 'Conversion service timed out',
userMessage: UNAVAILABLE_USER_MESSAGE,
statusCode: 503,
});
}
const errMessage = err instanceof Error ? err.message : String(err);
throw new AppError('CONVERSION_SERVICE_UNAVAILABLE', {
message: `Conversion service request failed: ${errMessage}`,
userMessage: UNAVAILABLE_USER_MESSAGE,
statusCode: 503,
});
} finally {
clearTimeout(timeoutHandle);
}
};
@@ -0,0 +1,43 @@
import { AppError } from '@documenso/lib/errors/app-error';
import type { Logger } from 'pino';
import { DOCUMENT_CONVERSION_MIME_TYPE_DOCX } from '../../constants/document-conversion';
import { convertDocxToPdf } from './docx-to-pdf';
// We should work on unifying these later on.
type FileInput = {
name: string;
type: string;
arrayBuffer: () => Promise<ArrayBuffer>;
};
const UNSUPPORTED_USER_MESSAGE = "This file type isn't supported. Please upload a PDF or Word document.";
/**
* Entry point for upload routes. Returns a PDF buffer for any supported
* input file:
*
* - PDF in → PDF out (no conversion, no network call).
* - DOCX in → converted PDF out via the configured conversion service.
* - Any other mime type → throws `UNSUPPORTED_FILE_TYPE`.
*
* To support new source formats (PowerPoint, HTML, ...), add a new
* `<format>-to-pdf.ts` sibling and dispatch to it from here.
*/
export const convertToPdf = async (file: FileInput, logger?: Logger): Promise<Buffer> => {
if (file.type === 'application/pdf') {
return Buffer.from(await file.arrayBuffer());
}
if (file.type === DOCUMENT_CONVERSION_MIME_TYPE_DOCX) {
const buffer = Buffer.from(await file.arrayBuffer());
return convertDocxToPdf({ buffer, filename: file.name }, logger);
}
throw new AppError('UNSUPPORTED_FILE_TYPE', {
message: `Unsupported file type: ${file.type}`,
userMessage: UNSUPPORTED_USER_MESSAGE,
statusCode: 400,
});
};
@@ -61,7 +61,7 @@ type RecipientGetEmailContextOptions = BaseGetEmailContextOptions & {
type GetEmailContextOptions = InternalGetEmailContextOptions | RecipientGetEmailContextOptions;
type EmailContextResponse = {
export type EmailContextResponse = {
allowedEmails: OrganisationEmail[];
branding: BrandingSettings;
settings: Omit<OrganisationGlobalSettings, 'id'>;
@@ -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,
});
};
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+8 -2
View File
@@ -20,5 +20,11 @@ export const env = <K extends EnvKey>(variable: K): EnvValue<K> => {
return (typeof process !== 'undefined' ? process?.env?.[variable] : undefined) as EnvValue<K>;
};
export const createPublicEnv = () =>
Object.fromEntries(Object.entries(process.env).filter(([key]) => key.startsWith('NEXT_PUBLIC_')));
export const createPublicEnv = () => ({
...Object.fromEntries(Object.entries(process.env).filter(([key]) => key.startsWith('NEXT_PUBLIC_'))),
// Derived from the private URL so the public flag cannot drift from the
// real server-side configuration. Placed last so it wins over any literal
// env var with the same name.
// The `? 'true' : 'false'` might seem dumb but it's because we're expecting env var strings.
NEXT_PUBLIC_DOCUMENT_CONVERSION_ENABLED: process.env.NEXT_PRIVATE_DOCUMENT_CONVERSION_URL ? 'true' : 'false',
});
@@ -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,
},
});
});
@@ -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<typeof ZDeleteOrganisationRequestSchema>;
export type TDeleteOrganisationResponse = z.infer<typeof ZDeleteOrganisationResponseSchema>;
@@ -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: {
@@ -1,5 +1,6 @@
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { convertToPdf } from '@documenso/lib/server-only/document-conversion';
import { createEnvelope } from '@documenso/lib/server-only/envelope/create-envelope';
import { insertFormValuesInPdf } from '@documenso/lib/server-only/pdf/insert-form-values-in-pdf';
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
@@ -35,7 +36,7 @@ export const createDocumentRoute = authenticatedProcedure
attachments,
} = payload;
let pdf = Buffer.from(await file.arrayBuffer());
let pdf = await convertToPdf(file, ctx.logger);
if (formValues) {
// eslint-disable-next-line require-atomic-updates
@@ -37,5 +37,6 @@ export const createEmbeddingEnvelopeRoute = procedure
bypassDefaultRecipients: true,
},
apiRequestMetadata: ctx.metadata,
logger: ctx.logger,
});
});
@@ -1,11 +1,13 @@
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { convertToPdf } from '@documenso/lib/server-only/document-conversion';
import { createEnvelope } from '@documenso/lib/server-only/envelope/create-envelope';
import { extractPdfPlaceholders } from '@documenso/lib/server-only/pdf/auto-place-fields';
import { normalizePdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { EnvelopeType } from '@prisma/client';
import type { Logger } from 'pino';
import { insertFormValuesInPdf } from '../../../lib/server-only/pdf/insert-form-values-in-pdf';
import { authenticatedProcedure } from '../trpc';
@@ -32,6 +34,7 @@ export const createEnvelopeRoute = authenticatedProcedure
teamId: ctx.teamId,
input,
apiRequestMetadata: ctx.metadata,
logger: ctx.logger,
});
});
@@ -48,6 +51,12 @@ type CreateEnvelopeRouteOptions = {
input: TCreateEnvelopeRequest;
apiRequestMetadata: ApiRequestMetadata;
/**
* Optional pino logger threaded from the calling tRPC context. Passed to
* downstream helpers (e.g. `convertToPdf`) for structured logging.
*/
logger?: Logger;
options?: {
bypassDefaultRecipients?: boolean;
};
@@ -58,6 +67,7 @@ export const createEnvelopeRouteCaller = async ({
teamId,
input,
apiRequestMetadata,
logger,
options = {},
}: CreateEnvelopeRouteOptions) => {
const { payload, files } = input;
@@ -96,17 +106,10 @@ export const createEnvelopeRouteCaller = async ({
});
}
if (files.some((file) => !file.type.startsWith('application/pdf'))) {
throw new AppError('INVALID_DOCUMENT_FILE', {
message: 'You cannot upload non-PDF files',
statusCode: 400,
});
}
// For each file: normalize, extract & clean placeholders, then upload.
// For each file: convert to PDF if needed, normalize, extract & clean placeholders, then upload.
const envelopeItems = await Promise.all(
files.map(async (file) => {
let pdf = Buffer.from(await file.arrayBuffer());
let pdf = await convertToPdf(file, logger);
if (formValues) {
// eslint-disable-next-line require-atomic-updates
@@ -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,
},
});
}
});
+13 -3
View File
@@ -3,6 +3,7 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { jobs } from '@documenso/lib/jobs/client';
import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
import { convertToPdf } from '@documenso/lib/server-only/document-conversion';
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
import { createEnvelope } from '@documenso/lib/server-only/envelope/create-envelope';
import { duplicateEnvelope } from '@documenso/lib/server-only/envelope/duplicate-envelope';
@@ -269,9 +270,18 @@ export const templateRouter = router({
attachments,
} = payload;
const { id: templateDocumentDataId } = await putNormalizedPdfFileServerSide(file, {
flattenForm: false,
});
const pdf = await convertToPdf(file, ctx.logger);
const { id: templateDocumentDataId } = await putNormalizedPdfFileServerSide(
{
name: file.name,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(pdf),
},
{
flattenForm: false,
},
);
ctx.logger.info({
input: {
+3 -4
View File
@@ -1,5 +1,6 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT, IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { getAllowedUploadMimeTypes } from '@documenso/lib/constants/document-conversion';
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
@@ -54,9 +55,7 @@ export const DocumentDropzone = ({
const organisation = useCurrentOrganisation();
const { getRootProps, getInputProps } = useDropzone({
accept: {
'application/pdf': ['.pdf'],
},
accept: getAllowedUploadMimeTypes(),
multiple: allowMultiple,
disabled,
onDrop: (acceptedFiles) => {
@@ -151,7 +150,7 @@ export const DocumentDropzone = ({
<p className="mt-6 font-medium text-foreground">{_(heading[type])}</p>
<p className="mt-1 text-center text-muted-foreground/80 text-sm">
{_(disabled ? disabledMessage : msg`Drag & drop your PDF here.`)}
{_(disabled ? disabledMessage : msg`Drag & drop your document here.`)}
</p>
{disabled && IS_BILLING_ENABLED() && (
@@ -1,6 +1,7 @@
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT, IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { getAllowedUploadMimeTypes } from '@documenso/lib/constants/document-conversion';
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import type { MessageDescriptor } from '@lingui/core';
@@ -51,9 +52,7 @@ export const DocumentUploadButton = ({
const isPersonalLayoutMode = isPersonalLayout(organisations);
const { getRootProps, getInputProps } = useDropzone({
accept: {
'application/pdf': ['.pdf'],
},
accept: getAllowedUploadMimeTypes(),
multiple: internalVersion === '2',
disabled,
maxFiles,