chore: team stuff (#1228)

- Added functionality to decline team invitations
- Added email notifications for when team is deleted
- Added email notifications for team members joining and leaving
This commit is contained in:
Ephraim Duncan
2024-07-25 04:27:19 +00:00
committed by GitHub
parent b366ab8736
commit a8febae87e
44 changed files with 1014 additions and 226 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@ -0,0 +1,46 @@
'use client';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type DeclineTeamInvitationButtonProps = {
teamId: number;
};
export const DeclineTeamInvitationButton = ({ teamId }: DeclineTeamInvitationButtonProps) => {
const { toast } = useToast();
const {
mutateAsync: declineTeamInvitation,
isLoading,
isSuccess,
} = trpc.team.declineTeamInvitation.useMutation({
onSuccess: () => {
toast({
title: 'Success',
description: 'Declined team invitation',
duration: 5000,
});
},
onError: () => {
toast({
title: 'Something went wrong',
variant: 'destructive',
duration: 10000,
description: 'Unable to decline this team invitation at this time.',
});
},
});
return (
<Button
onClick={async () => declineTeamInvitation({ teamId })}
loading={isLoading}
disabled={isLoading || isSuccess}
variant="ghost"
>
Decline
</Button>
);
};

View File

@ -19,6 +19,7 @@ import {
} from '@documenso/ui/primitives/dialog';
import { AcceptTeamInvitationButton } from './accept-team-invitation-button';
import { DeclineTeamInvitationButton } from './decline-team-invitation-button';
export const TeamInvitations = () => {
const { data, isInitialLoading } = trpc.team.getTeamInvitations.useQuery();
@ -68,7 +69,8 @@ export const TeamInvitations = () => {
}
secondaryText={formatTeamUrl(invitation.team.url)}
rightSideComponent={
<div className="ml-auto">
<div className="ml-auto space-x-2">
<DeclineTeamInvitationButton teamId={invitation.team.id} />
<AcceptTeamInvitationButton teamId={invitation.team.id} />
</div>
}

View File

@ -1,7 +1,5 @@
'use client';
import { useRouter } from 'next/navigation';
import { Edit, Loader, Mail, MoreHorizontal, X } from 'lucide-react';
import type { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
@ -14,6 +12,7 @@ import {
} from '@documenso/ui/primitives/dropdown-menu';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { RemoveTeamEmailDialog } from '~/components/(teams)/dialogs/remove-team-email-dialog';
import { UpdateTeamEmailDialog } from '~/components/(teams)/dialogs/update-team-email-dialog';
export type TeamsSettingsPageProps = {
@ -21,8 +20,6 @@ export type TeamsSettingsPageProps = {
};
export const TeamEmailDropdown = ({ team }: TeamsSettingsPageProps) => {
const router = useRouter();
const { toast } = useToast();
const { mutateAsync: resendEmailVerification, isLoading: isResendingEmailVerification } =
@ -44,56 +41,6 @@ export const TeamEmailDropdown = ({ team }: TeamsSettingsPageProps) => {
},
});
const { mutateAsync: deleteTeamEmail, isLoading: isDeletingTeamEmail } =
trpc.team.deleteTeamEmail.useMutation({
onSuccess: () => {
toast({
title: 'Success',
description: 'Team email has been removed',
duration: 5000,
});
},
onError: () => {
toast({
title: 'Something went wrong',
variant: 'destructive',
duration: 10000,
description: 'Unable to remove team email at this time. Please try again.',
});
},
});
const { mutateAsync: deleteTeamEmailVerification, isLoading: isDeletingTeamEmailVerification } =
trpc.team.deleteTeamEmailVerification.useMutation({
onSuccess: () => {
toast({
title: 'Success',
description: 'Email verification has been removed',
duration: 5000,
});
},
onError: () => {
toast({
title: 'Something went wrong',
variant: 'destructive',
duration: 10000,
description: 'Unable to remove email verification at this time. Please try again.',
});
},
});
const onRemove = async () => {
if (team.teamEmail) {
await deleteTeamEmail({ teamId: team.id });
}
if (team.emailVerification) {
await deleteTeamEmailVerification({ teamId: team.id });
}
router.refresh();
};
return (
<DropdownMenu>
<DropdownMenuTrigger>
@ -130,13 +77,16 @@ export const TeamEmailDropdown = ({ team }: TeamsSettingsPageProps) => {
/>
)}
<DropdownMenuItem
disabled={isDeletingTeamEmail || isDeletingTeamEmailVerification}
onClick={async () => onRemove()}
>
<X className="mr-2 h-4 w-4" />
Remove
</DropdownMenuItem>
<RemoveTeamEmailDialog
team={team}
teamName={team.name}
trigger={
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<X className="mr-2 h-4 w-4" />
Remove
</DropdownMenuItem>
}
/>
</DropdownMenuContent>
</DropdownMenu>
);

View File

@ -0,0 +1,120 @@
import Link from 'next/link';
import { DateTime } from 'luxon';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { encryptSecondaryData } from '@documenso/lib/server-only/crypto/encrypt';
import { declineTeamInvitation } from '@documenso/lib/server-only/team/decline-team-invitation';
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
import { prisma } from '@documenso/prisma';
import { TeamMemberInviteStatus } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button';
type DeclineInvitationPageProps = {
params: {
token: string;
};
};
export default async function DeclineInvitationPage({
params: { token },
}: DeclineInvitationPageProps) {
const session = await getServerComponentSession();
const teamMemberInvite = await prisma.teamMemberInvite.findUnique({
where: {
token,
},
});
if (!teamMemberInvite) {
return (
<div className="w-screen max-w-lg px-4">
<div className="w-full">
<h1 className="text-4xl font-semibold">Invalid token</h1>
<p className="text-muted-foreground mb-4 mt-2 text-sm">
This token is invalid or has expired. No action is needed.
</p>
<Button asChild>
<Link href="/">Return</Link>
</Button>
</div>
</div>
);
}
const team = await getTeamById({ teamId: teamMemberInvite.teamId });
const user = await prisma.user.findFirst({
where: {
email: {
equals: teamMemberInvite.email,
mode: 'insensitive',
},
},
});
if (user) {
await declineTeamInvitation({ userId: user.id, teamId: team.id });
}
if (!user && teamMemberInvite.status !== TeamMemberInviteStatus.DECLINED) {
await prisma.teamMemberInvite.update({
where: {
id: teamMemberInvite.id,
},
data: {
status: TeamMemberInviteStatus.DECLINED,
},
});
}
const email = encryptSecondaryData({
data: teamMemberInvite.email,
expiresAt: DateTime.now().plus({ days: 1 }).toMillis(),
});
if (!user) {
return (
<div>
<h1 className="text-4xl font-semibold">Team invitation</h1>
<p className="text-muted-foreground mt-2 text-sm">
You have been invited by <strong>{team.name}</strong> to join their team.
</p>
<p className="text-muted-foreground mb-4 mt-1 text-sm">
To decline this invitation you must create an account.
</p>
<Button asChild>
<Link href={`/signup?email=${encodeURIComponent(email)}`}>Create account</Link>
</Button>
</div>
);
}
const isSessionUserTheInvitedUser = user?.id === session.user?.id;
return (
<div className="w-screen max-w-lg px-4">
<h1 className="text-4xl font-semibold">Invitation declined</h1>
<p className="text-muted-foreground mb-4 mt-2 text-sm">
You have declined the invitation from <strong>{team.name}</strong> to join their team.
</p>
{isSessionUserTheInvitedUser ? (
<Button asChild>
<Link href="/">Return to Dashboard</Link>
</Button>
) : (
<Button asChild>
<Link href="/">Return to Home</Link>
</Button>
)}
</div>
);
}

View File

@ -113,10 +113,11 @@ export const DeleteTeamDialog = ({ trigger, teamId, teamName }: DeleteTeamDialog
<DialogContent position="center">
<DialogHeader>
<DialogTitle>Delete team</DialogTitle>
<DialogTitle>Are you sure you wish to delete this team?</DialogTitle>
<DialogDescription className="mt-4">
Are you sure? This is irreversable.
Please note that you will lose access to all documents associated with this team & all
the members will be removed and notified
</DialogDescription>
</DialogHeader>

View File

@ -0,0 +1,153 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import type { Prisma } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Alert } from '@documenso/ui/primitives/alert';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type RemoveTeamEmailDialogProps = {
trigger?: React.ReactNode;
teamName: string;
team: Prisma.TeamGetPayload<{
include: {
teamEmail: true;
emailVerification: {
select: {
expiresAt: true;
name: true;
email: true;
};
};
};
}>;
};
export const RemoveTeamEmailDialog = ({ trigger, teamName, team }: RemoveTeamEmailDialogProps) => {
const [open, setOpen] = useState(false);
const { toast } = useToast();
const router = useRouter();
const { mutateAsync: deleteTeamEmail, isLoading: isDeletingTeamEmail } =
trpc.team.deleteTeamEmail.useMutation({
onSuccess: () => {
toast({
title: 'Success',
description: 'Team email has been removed',
duration: 5000,
});
},
onError: () => {
toast({
title: 'Something went wrong',
variant: 'destructive',
duration: 10000,
description: 'Unable to remove team email at this time. Please try again.',
});
},
});
const { mutateAsync: deleteTeamEmailVerification, isLoading: isDeletingTeamEmailVerification } =
trpc.team.deleteTeamEmailVerification.useMutation({
onSuccess: () => {
toast({
title: 'Success',
description: 'Email verification has been removed',
duration: 5000,
});
},
onError: () => {
toast({
title: 'Something went wrong',
variant: 'destructive',
duration: 10000,
description: 'Unable to remove email verification at this time. Please try again.',
});
},
});
const onRemove = async () => {
if (team.teamEmail) {
await deleteTeamEmail({ teamId: team.id });
}
if (team.emailVerification) {
await deleteTeamEmailVerification({ teamId: team.id });
}
router.refresh();
};
return (
<Dialog open={open} onOpenChange={(value) => setOpen(value)}>
<DialogTrigger asChild>
{trigger ?? <Button variant="destructive">Remove team email</Button>}
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>Are you sure?</DialogTitle>
<DialogDescription className="mt-4">
You are about to delete the following team email from{' '}
<span className="font-semibold">{teamName}</span>.
</DialogDescription>
</DialogHeader>
<Alert variant="neutral" padding="tight">
<AvatarWithText
avatarClass="h-12 w-12"
avatarSrc={`${NEXT_PUBLIC_WEBAPP_URL()}/api/avatar/${team.avatarImageId}`}
avatarFallback={extractInitials(
(team.teamEmail?.name || team.emailVerification?.name) ?? '',
)}
primaryText={
<span className="text-foreground/80 text-sm font-semibold">
{team.teamEmail?.name || team.emailVerification?.name}
</span>
}
secondaryText={
<span className="text-sm">
{team.teamEmail?.email || team.emailVerification?.email}
</span>
}
/>
</Alert>
<fieldset disabled={isDeletingTeamEmail || isDeletingTeamEmailVerification}>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
type="submit"
variant="destructive"
loading={isDeletingTeamEmail || isDeletingTeamEmailVerification}
onClick={async () => onRemove()}
>
Remove
</Button>
</DialogFooter>
</fieldset>
</DialogContent>
</Dialog>
);
};

View File

@ -3,7 +3,7 @@ import { expect, test } from '@playwright/test';
import { createDocumentAuthOptions } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma';
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
import { seedUser, unseedUser } from '@documenso/prisma/seed/users';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
@ -31,8 +31,6 @@ test('[DOCUMENT_AUTH]: should grant access when not required', async ({ page })
await page.goto(`/sign/${token}`);
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
}
await unseedUser(user.id);
});
test('[DOCUMENT_AUTH]: should allow or deny access when required', async ({ page }) => {
@ -90,7 +88,4 @@ test('[DOCUMENT_AUTH]: should allow or deny access when required', async ({ page
await expect(page.getByRole('paragraph')).toContainText(email);
}
}
await unseedUser(user.id);
await unseedUser(recipientWithAccount.id);
});

View File

@ -10,7 +10,7 @@ import {
seedPendingDocumentNoFields,
seedPendingDocumentWithFullFields,
} from '@documenso/prisma/seed/documents';
import { seedTestEmail, seedUser, unseedUser } from '@documenso/prisma/seed/users';
import { seedTestEmail, seedUser } from '@documenso/prisma/seed/users';
import { apiSignin, apiSignout } from '../fixtures/authentication';
@ -60,9 +60,6 @@ test('[DOCUMENT_AUTH]: should allow signing when no auth setup', async ({ page }
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(`${signUrl}/complete`);
}
await unseedUser(user.id);
await unseedUser(recipientWithAccount.id);
});
test('[DOCUMENT_AUTH]: should allow signing with valid global auth', async ({ page }) => {
@ -119,9 +116,6 @@ test('[DOCUMENT_AUTH]: should allow signing with valid global auth', async ({ pa
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(`${signUrl}/complete`);
await unseedUser(user.id);
await unseedUser(recipientWithAccount.id);
});
// Currently document auth for signing/approving/viewing is not required.
@ -154,9 +148,6 @@ test.skip('[DOCUMENT_AUTH]: should deny signing document when required for globa
await expect(page.getByRole('paragraph')).toContainText(
'Reauthentication is required to sign the document',
);
await unseedUser(user.id);
await unseedUser(recipientWithAccount.id);
});
test('[DOCUMENT_AUTH]: should deny signing fields when required for global auth', async ({
@ -196,9 +187,6 @@ test('[DOCUMENT_AUTH]: should deny signing fields when required for global auth'
await page.getByRole('button', { name: 'Cancel' }).click();
}
}
await unseedUser(user.id);
await unseedUser(recipientWithAccount.id);
});
test('[DOCUMENT_AUTH]: should allow field signing when required for recipient auth', async ({

View File

@ -6,8 +6,8 @@ import {
seedPendingDocument,
} from '@documenso/prisma/seed/documents';
import { seedUserSubscription } from '@documenso/prisma/seed/subscriptions';
import { seedTeam, unseedTeam } from '@documenso/prisma/seed/teams';
import { seedUser, unseedUser } from '@documenso/prisma/seed/users';
import { seedTeam } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
@ -53,8 +53,6 @@ test.describe('[EE_ONLY]', () => {
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
await unseedUser(user.id);
});
test('[DOCUMENT_FLOW] enterprise team member can add action auth settings', async ({ page }) => {
@ -94,8 +92,6 @@ test.describe('[EE_ONLY]', () => {
// Advanced settings should be visible.
await expect(page.getByLabel('Show advanced settings')).toBeVisible();
await unseedTeam(team.url);
});
test('[DOCUMENT_FLOW] enterprise team member should not have access to enterprise on personal account', async ({
@ -130,8 +126,6 @@ test.describe('[EE_ONLY]', () => {
// Advanced settings should not be visible.
await expect(page.getByLabel('Show advanced settings')).not.toBeVisible();
await unseedTeam(team.url);
});
});
@ -166,8 +160,6 @@ test('[DOCUMENT_FLOW]: add settings', async ({ page }) => {
await expect(page.getByLabel('Title')).toHaveValue('New Title');
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
await unseedUser(user.id);
});
test('[DOCUMENT_FLOW]: title should be disabled depending on document status', async ({ page }) => {
@ -188,6 +180,4 @@ test('[DOCUMENT_FLOW]: title should be disabled depending on document status', a
// Should be enabled for draft documents.
await page.goto(`/documents/${draftDocument.id}/edit`);
await expect(page.getByLabel('Title')).toBeEnabled();
await unseedUser(user.id);
});

View File

@ -2,7 +2,7 @@ import { expect, test } from '@playwright/test';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUserSubscription } from '@documenso/prisma/seed/subscriptions';
import { seedUser, unseedUser } from '@documenso/prisma/seed/users';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
@ -57,8 +57,6 @@ test.describe('[EE_ONLY]', () => {
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// Todo: Fix stepper component back issue before finishing test.
await unseedUser(user.id);
});
});
@ -91,6 +89,4 @@ test('[DOCUMENT_FLOW]: add signers', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Go Back' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
await unseedUser(user.id);
});

View File

@ -9,7 +9,7 @@ import {
seedBlankDocument,
seedPendingDocumentWithFullFields,
} from '@documenso/prisma/seed/documents';
import { seedUser, unseedUser } from '@documenso/prisma/seed/users';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
@ -107,8 +107,6 @@ test('[DOCUMENT_FLOW]: should be able to create a document', async ({ page }) =>
// Assert document was created
await expect(page.getByRole('link', { name: documentTitle })).toBeVisible();
await unseedUser(user.id);
});
test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipients', async ({
@ -192,8 +190,6 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
// Assert document was created
await expect(page.getByRole('link', { name: documentTitle })).toBeVisible();
await unseedUser(user.id);
});
test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipients with different roles', async ({
@ -291,8 +287,6 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
// Assert document was created
await expect(page.getByRole('link', { name: 'Test Title' })).toBeVisible();
await unseedUser(user.id);
});
test('[DOCUMENT_FLOW]: should not be able to create a document without signatures', async ({
@ -331,8 +325,6 @@ test('[DOCUMENT_FLOW]: should not be able to create a document without signature
await expect(
page.getByRole('dialog').getByText('No signature field found').first(),
).toBeVisible();
await unseedUser(user.id);
});
test('[DOCUMENT_FLOW]: should be able to approve a document', async ({ page }) => {
@ -388,8 +380,6 @@ test('[DOCUMENT_FLOW]: should be able to approve a document', async ({ page }) =
.click();
await page.waitForURL(`${signUrl}/complete`);
}
await unseedUser(user.id);
});
test('[DOCUMENT_FLOW]: should be able to create, send with redirect url, sign a document and redirect to redirect url', async ({
@ -462,8 +452,6 @@ test('[DOCUMENT_FLOW]: should be able to create, send with redirect url, sign a
// Check if document has been signed
const { status: completedStatus } = await getDocumentByToken(token);
expect(completedStatus).toBe(DocumentStatus.COMPLETED);
await unseedUser(user.id);
});
test('[DOCUMENT_FLOW]: should be able to sign a document with custom date', async ({ page }) => {
@ -505,6 +493,4 @@ test('[DOCUMENT_FLOW]: should be able to sign a document with custom date', asyn
// Check if document has been signed
const { status: completedStatus } = await getDocumentByToken(token);
expect(completedStatus).toBe(DocumentStatus.COMPLETED);
await unseedUser(user.id);
});

View File

@ -1,7 +1,7 @@
import { test } from '@playwright/test';
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
import { seedTeam, unseedTeam } from '@documenso/prisma/seed/teams';
import { seedTeam } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
@ -33,8 +33,6 @@ test('[TEAMS]: create team', async ({ page }) => {
// Goto new team settings page.
await page.getByRole('row').filter({ hasText: teamId }).getByRole('link').nth(1).click();
await unseedTeam(teamId);
});
test('[TEAMS]: delete team', async ({ page }) => {
@ -84,6 +82,4 @@ test('[TEAMS]: update team', async ({ page }) => {
// Check we have been redirected to the new team URL and the name is updated.
await page.waitForURL(`${WEBAPP_BASE_URL}/t/${updatedTeamId}/settings`);
await unseedTeam(updatedTeamId);
});

View File

@ -2,7 +2,7 @@ import { expect, test } from '@playwright/test';
import { DocumentStatus } from '@documenso/prisma/client';
import { seedDocuments, seedTeamDocuments } from '@documenso/prisma/seed/documents';
import { seedTeamEmail, unseedTeam, unseedTeamEmail } from '@documenso/prisma/seed/teams';
import { seedTeamEmail } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin, apiSignout } from '../fixtures/authentication';
@ -42,8 +42,6 @@ test('[TEAMS]: check team documents count', async ({ page }) => {
await apiSignout({ page });
}
await unseedTeam(team.url);
});
test('[TEAMS]: check team documents count with internal team email', async ({ page }) => {
@ -138,9 +136,6 @@ test('[TEAMS]: check team documents count with internal team email', async ({ pa
await apiSignout({ page });
}
await unseedTeamEmail({ teamId: team.id });
await unseedTeam(team.url);
});
test('[TEAMS]: check team documents count with external team email', async ({ page }) => {
@ -225,9 +220,6 @@ test('[TEAMS]: check team documents count with external team email', async ({ pa
await checkDocumentTabCount(page, 'Completed', 0);
await checkDocumentTabCount(page, 'Draft', 1);
await checkDocumentTabCount(page, 'All', 3);
await unseedTeamEmail({ teamId: team.id });
await unseedTeam(team.url);
});
test('[TEAMS]: resend pending team document', async ({ page }) => {
@ -284,8 +276,6 @@ test('[TEAMS]: delete draft team document', async ({ page }) => {
await apiSignout({ page });
}
await unseedTeam(team.url);
});
test('[TEAMS]: delete pending team document', async ({ page }) => {
@ -325,8 +315,6 @@ test('[TEAMS]: delete pending team document', async ({ page }) => {
await apiSignout({ page });
}
await unseedTeam(team.url);
});
test('[TEAMS]: delete completed team document', async ({ page }) => {
@ -366,6 +354,4 @@ test('[TEAMS]: delete completed team document', async ({ page }) => {
await apiSignout({ page });
}
await unseedTeam(team.url);
});

View File

@ -1,8 +1,8 @@
import { expect, test } from '@playwright/test';
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
import { seedTeam, seedTeamEmailVerification, unseedTeam } from '@documenso/prisma/seed/teams';
import { seedUser, unseedUser } from '@documenso/prisma/seed/users';
import { seedTeam, seedTeamEmailVerification } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
@ -31,8 +31,6 @@ test('[TEAMS]: send team email request', async ({ page }) => {
.filter({ hasText: 'We have sent a confirmation email for verification.' })
.first(),
).toBeVisible();
await unseedTeam(team.url);
});
test('[TEAMS]: accept team email request', async ({ page }) => {
@ -41,14 +39,12 @@ test('[TEAMS]: accept team email request', async ({ page }) => {
});
const teamEmailVerification = await seedTeamEmailVerification({
email: 'team-email-verification@test.documenso.com',
email: `team-email-verification--${team.url}@test.documenso.com`,
teamId: team.id,
});
await page.goto(`${WEBAPP_BASE_URL}/team/verify/email/${teamEmailVerification.token}`);
await expect(page.getByRole('heading')).toContainText('Team email verified!');
await unseedTeam(team.url);
});
test('[TEAMS]: delete team email', async ({ page }) => {
@ -66,10 +62,9 @@ test('[TEAMS]: delete team email', async ({ page }) => {
await page.locator('section div').filter({ hasText: 'Team email' }).getByRole('button').click();
await page.getByRole('menuitem', { name: 'Remove' }).click();
await page.getByRole('button', { name: 'Remove' }).click();
await expect(page.getByText('Team email has been removed').first()).toBeVisible();
await unseedTeam(team.url);
});
test('[TEAMS]: team email owner removes access', async ({ page }) => {
@ -96,7 +91,4 @@ test('[TEAMS]: team email owner removes access', async ({ page }) => {
await page.getByRole('button', { name: 'Revoke' }).click();
await expect(page.getByText('You have successfully revoked').first()).toBeVisible();
await unseedTeam(team.url);
await unseedUser(teamEmailOwner.id);
});

View File

@ -1,7 +1,7 @@
import { expect, test } from '@playwright/test';
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
import { seedTeam, seedTeamInvite, unseedTeam } from '@documenso/prisma/seed/teams';
import { seedTeam, seedTeamInvite } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
@ -35,8 +35,6 @@ test('[TEAMS]: update team member role', async ({ page }) => {
await expect(
page.getByRole('row').filter({ hasText: teamMemberToUpdate.user.email }),
).toContainText('Manager');
await unseedTeam(team.url);
});
test('[TEAMS]: accept team invitation without account', async ({ page }) => {
@ -49,8 +47,6 @@ test('[TEAMS]: accept team invitation without account', async ({ page }) => {
await page.goto(`${WEBAPP_BASE_URL}/team/invite/${teamInvite.token}`);
await expect(page.getByRole('heading')).toContainText('Team invitation');
await unseedTeam(team.url);
});
test('[TEAMS]: accept team invitation with account', async ({ page }) => {
@ -64,8 +60,6 @@ test('[TEAMS]: accept team invitation with account', async ({ page }) => {
await page.goto(`${WEBAPP_BASE_URL}/team/invite/${teamInvite.token}`);
await expect(page.getByRole('heading')).toContainText('Invitation accepted!');
await unseedTeam(team.url);
});
test('[TEAMS]: member can leave team', async ({ page }) => {
@ -88,8 +82,6 @@ test('[TEAMS]: member can leave team', async ({ page }) => {
await expect(page.getByRole('status').first()).toContainText(
'You have successfully left this team.',
);
await unseedTeam(team.url);
});
test('[TEAMS]: owner cannot leave team', async ({ page }) => {
@ -105,6 +97,4 @@ test('[TEAMS]: owner cannot leave team', async ({ page }) => {
});
await expect(page.getByRole('button').getByText('Leave')).toBeDisabled();
await unseedTeam(team.url);
});

View File

@ -1,7 +1,7 @@
import { expect, test } from '@playwright/test';
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
import { seedTeam, seedTeamTransfer, unseedTeam } from '@documenso/prisma/seed/teams';
import { seedTeam, seedTeamTransfer } from '@documenso/prisma/seed/teams';
import { apiSignin } from '../fixtures/authentication';
@ -43,8 +43,6 @@ test('[TEAMS]: initiate and cancel team transfer', async ({ page }) => {
await expect(page.getByRole('status').first()).toContainText(
'The team transfer invitation has been successfully deleted.',
);
await unseedTeam(team.url);
});
/**
@ -64,6 +62,4 @@ test.skip('[TEAMS]: accept team transfer', async ({ page }) => {
await page.goto(`${WEBAPP_BASE_URL}/team/verify/transfer/${teamTransferRequest.token}`);
await expect(page.getByRole('heading')).toContainText('Team ownership transferred!');
await unseedTeam(team.url);
});

View File

@ -1,9 +1,9 @@
import { expect, test } from '@playwright/test';
import { seedUserSubscription } from '@documenso/prisma/seed/subscriptions';
import { seedTeam, unseedTeam } from '@documenso/prisma/seed/teams';
import { seedTeam } from '@documenso/prisma/seed/teams';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser, unseedUser } from '@documenso/prisma/seed/users';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
@ -49,8 +49,6 @@ test.describe('[EE_ONLY]', () => {
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require passkey');
await unseedUser(user.id);
});
test('[TEMPLATE_FLOW] enterprise team member can add action auth settings', async ({ page }) => {
@ -90,8 +88,6 @@ test.describe('[EE_ONLY]', () => {
// Advanced settings should be visible.
await expect(page.getByLabel('Show advanced settings')).toBeVisible();
await unseedTeam(team.url);
});
test('[TEMPLATE_FLOW] enterprise team member should not have access to enterprise on personal account', async ({
@ -126,8 +122,6 @@ test.describe('[EE_ONLY]', () => {
// Advanced settings should not be visible.
await expect(page.getByLabel('Show advanced settings')).not.toBeVisible();
await unseedTeam(team.url);
});
});
@ -162,6 +156,4 @@ test('[TEMPLATE_FLOW]: add settings', async ({ page }) => {
await expect(page.getByLabel('Title')).toHaveValue('New Title');
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
await unseedUser(user.id);
});

View File

@ -2,7 +2,7 @@ import { expect, test } from '@playwright/test';
import { seedUserSubscription } from '@documenso/prisma/seed/subscriptions';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser, unseedUser } from '@documenso/prisma/seed/users';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
@ -73,8 +73,6 @@ test.describe('[EE_ONLY]', () => {
// Expect that the advanced settings is visible, and the checkbox is hidden. Since advanced
// settings were applied.
await expect(page.getByLabel('Show advanced settings')).toBeHidden();
await unseedUser(user.id);
});
});
@ -101,6 +99,4 @@ test('[TEMPLATE_FLOW]: add placeholder', async ({ page }) => {
// Advanced settings should not be visible for non EE users.
await expect(page.getByLabel('Show advanced settings')).toBeHidden();
await unseedUser(user.id);
});

View File

@ -9,9 +9,9 @@ import {
import { createDocumentAuthOptions } from '@documenso/lib/utils/document-auth';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { formatDirectTemplatePath } from '@documenso/lib/utils/templates';
import { seedTeam, unseedTeam } from '@documenso/prisma/seed/teams';
import { seedTeam } from '@documenso/prisma/seed/teams';
import { seedDirectTemplate, seedTemplate } from '@documenso/prisma/seed/templates';
import { seedTestEmail, seedUser, unseedUser } from '@documenso/prisma/seed/users';
import { seedTestEmail, seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
import { checkDocumentTabCount } from '../fixtures/documents';
@ -67,8 +67,6 @@ test('[DIRECT_TEMPLATES]: create direct link for template', async ({ page }) =>
// Expect badge to appear.
await expect(page.getByRole('button', { name: 'direct link' })).toHaveCount(2);
}
await unseedTeam(team.url);
});
test('[DIRECT_TEMPLATES]: toggle direct template link', async ({ page }) => {
@ -115,8 +113,6 @@ test('[DIRECT_TEMPLATES]: toggle direct template link', async ({ page }) => {
await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
await expect(page.getByText('Template not found')).toBeVisible();
}
await unseedTeam(team.url);
});
test('[DIRECT_TEMPLATES]: delete direct template link', async ({ page }) => {
@ -162,8 +158,6 @@ test('[DIRECT_TEMPLATES]: delete direct template link', async ({ page }) => {
await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
await expect(page.getByText('Template not found')).toBeVisible();
}
await unseedTeam(team.url);
});
test('[DIRECT_TEMPLATES]: direct template link auth access', async ({ page }) => {
@ -197,8 +191,6 @@ test('[DIRECT_TEMPLATES]: direct template link auth access', async ({ page }) =>
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
await expect(page.getByLabel('Email')).toBeDisabled();
await unseedUser(user.id);
});
test('[DIRECT_TEMPLATES]: use direct template link with 1 recipient', async ({ page }) => {
@ -248,8 +240,6 @@ test('[DIRECT_TEMPLATES]: use direct template link with 1 recipient', async ({ p
// Check that the document is in the 'All' tab.
await checkDocumentTabCount(page, 'Completed', 1);
}
await unseedTeam(team.url);
});
test('[DIRECT_TEMPLATES]: use direct template link with 2 recipients', async ({ page }) => {
@ -333,7 +323,4 @@ test('[DIRECT_TEMPLATES]: use direct template link with 2 recipients', async ({
await checkDocumentTabCount(page, 'All', 2);
await checkDocumentTabCount(page, 'Inbox', 2);
await unseedTeam(team.url);
await unseedUser(secondRecipient.id);
});

View File

@ -1,7 +1,7 @@
import { expect, test } from '@playwright/test';
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
import { seedTeam, unseedTeam } from '@documenso/prisma/seed/teams';
import { seedTeam } from '@documenso/prisma/seed/teams';
import { seedTemplate } from '@documenso/prisma/seed/templates';
import { apiSignin } from '../fixtures/authentication';
@ -49,8 +49,6 @@ test('[TEMPLATES]: view templates', async ({ page }) => {
// Only should only see their personal template.
await page.goto(`${WEBAPP_BASE_URL}/templates`);
await expect(page.getByRole('main')).toContainText('Showing 1 result');
await unseedTeam(team.url);
});
test('[TEMPLATES]: delete template', async ({ page }) => {
@ -110,8 +108,6 @@ test('[TEMPLATES]: delete template', async ({ page }) => {
await page.reload();
}
await unseedTeam(team.url);
});
test('[TEMPLATES]: duplicate template', async ({ page }) => {
@ -156,8 +152,6 @@ test('[TEMPLATES]: duplicate template', async ({ page }) => {
await page.getByRole('button', { name: 'Duplicate' }).click();
await expect(page.getByText('Template duplicated').first()).toBeVisible();
await expect(page.getByRole('main')).toContainText('Showing 2 results');
await unseedTeam(team.url);
});
test('[TEMPLATES]: use template', async ({ page }) => {
@ -219,6 +213,4 @@ test('[TEMPLATES]: use template', async ({ page }) => {
await page.getByRole('main').getByRole('link', { name: 'Documents' }).click();
await page.waitForURL(`/t/${team.url}/documents`);
await expect(page.getByRole('main')).toContainText('Showing 1 result');
await unseedTeam(team.url);
});

View File

@ -4,8 +4,6 @@ import {
extractUserVerificationToken,
seedTestEmail,
seedUser,
unseedUser,
unseedUserByEmail,
} from '@documenso/prisma/seed/users';
test.use({ storageState: { cookies: [], origins: [] } });
@ -48,7 +46,6 @@ test('[USER] can sign up with email and password', async ({ page }: { page: Page
await page.waitForURL('/documents');
await expect(page).toHaveURL('/documents');
await unseedUserByEmail(email);
});
test('[USER] can sign in using email and password', async ({ page }: { page: Page }) => {
@ -61,6 +58,4 @@ test('[USER] can sign in using email and password', async ({ page }: { page: Pag
await page.waitForURL('/documents');
await expect(page).toHaveURL('/documents');
await unseedUser(user.id);
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@ -0,0 +1,86 @@
import { formatTeamUrl } from '@documenso/lib/utils/teams';
import config from '@documenso/tailwind-config';
import { Body, Container, Head, Hr, Html, Preview, Section, Tailwind, Text } from '../components';
import { TemplateFooter } from '../template-components/template-footer';
import TemplateImage from '../template-components/template-image';
export type TeamDeleteEmailProps = {
assetBaseUrl: string;
baseUrl: string;
teamUrl: string;
isOwner: boolean;
};
export const TeamDeleteEmailTemplate = ({
assetBaseUrl = 'http://localhost:3002',
baseUrl = 'https://documenso.com',
teamUrl = 'demo',
isOwner = false,
}: TeamDeleteEmailProps) => {
const previewText = isOwner
? 'Your team has been deleted'
: 'A team you were a part of has been deleted';
const title = isOwner
? 'Your team has been deleted'
: 'A team you were a part of has been deleted';
const description = isOwner
? 'The following team has been deleted by you'
: 'The following team has been deleted by its owner. You will no longer be able to access this team and its documents';
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind
config={{
theme: {
extend: {
colors: config.theme.extend.colors,
},
},
}}
>
<Body className="mx-auto my-auto font-sans">
<Section className="bg-white text-slate-500">
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-2 backdrop-blur-sm">
<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 text-lg font-medium text-black">{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 text-base font-medium text-slate-600">
{formatTeamUrl(teamUrl, baseUrl)}
</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>
</Tailwind>
</Html>
);
};
export default TeamDeleteEmailTemplate;

View File

@ -89,6 +89,12 @@ export const TeamInviteEmailTemplate = ({
>
Accept
</Button>
<Button
className="ml-4 inline-flex items-center justify-center rounded-lg bg-gray-50 px-6 py-3 text-center text-sm font-medium text-slate-600 no-underline"
href={`${baseUrl}/team/decline/${token}`}
>
Decline
</Button>
</Section>
</Section>
</Container>

View File

@ -0,0 +1,84 @@
import { formatTeamUrl } from '@documenso/lib/utils/teams';
import config from '@documenso/tailwind-config';
import { Body, Container, Head, Hr, Html, Preview, Section, Tailwind, Text } from '../components';
import { TemplateFooter } from '../template-components/template-footer';
import TemplateImage from '../template-components/template-image';
export type TeamJoinEmailProps = {
assetBaseUrl: string;
baseUrl: string;
memberName: string;
memberEmail: string;
teamName: string;
teamUrl: string;
};
export const TeamJoinEmailTemplate = ({
assetBaseUrl = 'http://localhost:3002',
baseUrl = 'https://documenso.com',
memberName = 'John Doe',
memberEmail = 'johndoe@documenso.com',
teamName = 'Team Name',
teamUrl = 'demo',
}: TeamJoinEmailProps) => {
const previewText = 'A team member has joined a team on Documenso';
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind
config={{
theme: {
extend: {
colors: config.theme.extend.colors,
},
},
}}
>
<Body className="mx-auto my-auto font-sans">
<Section className="bg-white text-slate-500">
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-2 backdrop-blur-sm">
<TemplateImage
assetBaseUrl={assetBaseUrl}
className="mb-4 h-6 p-2"
staticAsset="logo.png"
/>
<Section>
<TemplateImage
className="mx-auto"
assetBaseUrl={assetBaseUrl}
staticAsset="add-user.png"
/>
</Section>
<Section className="p-2 text-slate-500">
<Text className="text-center text-lg font-medium text-black">
{memberName || memberEmail} joined the team {teamName} on Documenso
</Text>
<Text className="my-1 text-center text-base">
{memberEmail} joined the following team
</Text>
<div className="mx-auto my-2 w-fit rounded-lg bg-gray-50 px-4 py-2 text-base font-medium text-slate-600">
{formatTeamUrl(teamUrl, baseUrl)}
</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>
</Tailwind>
</Html>
);
};
export default TeamJoinEmailTemplate;

View File

@ -0,0 +1,84 @@
import { formatTeamUrl } from '@documenso/lib/utils/teams';
import config from '@documenso/tailwind-config';
import { Body, Container, Head, Hr, Html, Preview, Section, Tailwind, Text } from '../components';
import { TemplateFooter } from '../template-components/template-footer';
import TemplateImage from '../template-components/template-image';
export type TeamLeaveEmailProps = {
assetBaseUrl: string;
baseUrl: string;
memberName: string;
memberEmail: string;
teamName: string;
teamUrl: string;
};
export const TeamLeaveEmailTemplate = ({
assetBaseUrl = 'http://localhost:3002',
baseUrl = 'https://documenso.com',
memberName = 'John Doe',
memberEmail = 'johndoe@documenso.com',
teamName = 'Team Name',
teamUrl = 'demo',
}: TeamLeaveEmailProps) => {
const previewText = 'A team member has left a team on Documenso';
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind
config={{
theme: {
extend: {
colors: config.theme.extend.colors,
},
},
}}
>
<Body className="mx-auto my-auto font-sans">
<Section className="bg-white text-slate-500">
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-2 backdrop-blur-sm">
<TemplateImage
assetBaseUrl={assetBaseUrl}
className="mb-4 h-6 p-2"
staticAsset="logo.png"
/>
<Section>
<TemplateImage
className="mx-auto"
assetBaseUrl={assetBaseUrl}
staticAsset="delete-user.png"
/>
</Section>
<Section className="p-2 text-slate-500">
<Text className="text-center text-lg font-medium text-black">
{memberName || memberEmail} left the team {teamName} on Documenso
</Text>
<Text className="my-1 text-center text-base">
{memberEmail} left the following team
</Text>
<div className="mx-auto my-2 w-fit rounded-lg bg-gray-50 px-4 py-2 text-base font-medium text-slate-600">
{formatTeamUrl(teamUrl, baseUrl)}
</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>
</Tailwind>
</Html>
);
};
export default TeamLeaveEmailTemplate;

View File

@ -1,6 +1,9 @@
import { JobClient } from './client/client';
import { SEND_CONFIRMATION_EMAIL_JOB_DEFINITION } from './definitions/send-confirmation-email';
import { SEND_SIGNING_EMAIL_JOB_DEFINITION } from './definitions/send-signing-email';
import { SEND_CONFIRMATION_EMAIL_JOB_DEFINITION } from './definitions/emails/send-confirmation-email';
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 { SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-member-joined-email';
import { SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-member-left-email';
/**
* The `as const` assertion is load bearing as it provides the correct level of type inference for
@ -9,4 +12,9 @@ import { SEND_SIGNING_EMAIL_JOB_DEFINITION } from './definitions/send-signing-em
export const jobsClient = new JobClient([
SEND_SIGNING_EMAIL_JOB_DEFINITION,
SEND_CONFIRMATION_EMAIL_JOB_DEFINITION,
SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION,
SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION,
SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION,
] as const);
export const jobs = jobsClient;

View File

@ -1,7 +1,7 @@
import { z } from 'zod';
import { sendConfirmationToken } from '../../server-only/user/send-confirmation-token';
import type { JobDefinition } from '../client/_internal/job';
import { sendConfirmationToken } from '../../../server-only/user/send-confirmation-token';
import type { JobDefinition } from '../../client/_internal/job';
const SEND_CONFIRMATION_EMAIL_JOB_DEFINITION_ID = 'send.signup.confirmation.email';

View File

@ -13,17 +13,17 @@ import {
SendStatus,
} from '@documenso/prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
import {
RECIPIENT_ROLES_DESCRIPTION,
RECIPIENT_ROLE_TO_EMAIL_TYPE,
} from '../../constants/recipient-roles';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { ZRequestMetadataSchema } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { renderCustomEmailTemplate } from '../../utils/render-custom-email-template';
import { type JobDefinition } from '../client/_internal/job';
} from '../../../constants/recipient-roles';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs';
import { ZRequestMetadataSchema } from '../../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../../utils/document-audit-logs';
import { renderCustomEmailTemplate } from '../../../utils/render-custom-email-template';
import { type JobDefinition } from '../../client/_internal/job';
const SEND_SIGNING_EMAIL_JOB_DEFINITION_ID = 'send.signing.requested.email';

View File

@ -0,0 +1,48 @@
import { z } from 'zod';
import { sendTeamDeleteEmail } from '../../../server-only/team/delete-team';
import type { JobDefinition } from '../../client/_internal/job';
const SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION_ID = 'send.team-deleted.email';
const SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
team: z.object({
name: z.string(),
url: z.string(),
ownerUserId: z.number(),
}),
members: z.array(
z.object({
id: z.number(),
name: z.string(),
email: z.string(),
}),
),
});
export const SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION = {
id: SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION_ID,
name: 'Send Team Deleted Email',
version: '1.0.0',
trigger: {
name: SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION_ID,
schema: SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const { team, members } = payload;
for (const member of members) {
await io.runTask(`send-team-deleted-email--${team.url}_${member.id}`, async () => {
await sendTeamDeleteEmail({
email: member.email,
teamName: team.name,
teamUrl: team.url,
isOwner: member.id === team.ownerUserId,
});
});
}
},
} as const satisfies JobDefinition<
typeof SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION_ID,
z.infer<typeof SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION_SCHEMA>
>;

View File

@ -0,0 +1,91 @@
import { z } from 'zod';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import TeamJoinEmailTemplate from '@documenso/email/templates/team-join';
import { prisma } from '@documenso/prisma';
import { TeamMemberRole } from '@documenso/prisma/client';
import { WEBAPP_BASE_URL } from '../../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
import type { JobDefinition } from '../../client/_internal/job';
const SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION_ID = 'send.team-member-joined.email';
const SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
teamId: z.number(),
memberId: z.number(),
});
export const SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION = {
id: SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION_ID,
name: 'Send Team Member Joined Email',
version: '1.0.0',
trigger: {
name: SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION_ID,
schema: SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const team = await prisma.team.findFirstOrThrow({
where: {
id: payload.teamId,
},
include: {
members: {
where: {
role: {
in: [TeamMemberRole.ADMIN, TeamMemberRole.MANAGER],
},
},
include: {
user: true,
},
},
},
});
const invitedMember = await prisma.teamMember.findFirstOrThrow({
where: {
id: payload.memberId,
teamId: payload.teamId,
},
include: {
user: true,
},
});
for (const member of team.members) {
if (member.id === invitedMember.id) {
continue;
}
await io.runTask(
`send-team-member-joined-email--${invitedMember.id}_${member.id}`,
async () => {
const emailContent = TeamJoinEmailTemplate({
assetBaseUrl: WEBAPP_BASE_URL,
baseUrl: WEBAPP_BASE_URL,
memberName: invitedMember.user.name || '',
memberEmail: invitedMember.user.email,
teamName: team.name,
teamUrl: team.url,
});
await mailer.sendMail({
to: member.user.email,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: 'A new member has joined your team',
html: render(emailContent),
text: render(emailContent, { plainText: true }),
});
},
);
}
},
} as const satisfies JobDefinition<
typeof SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION_ID,
z.infer<typeof SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION_SCHEMA>
>;

View File

@ -0,0 +1,80 @@
import { z } from 'zod';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import TeamJoinEmailTemplate from '@documenso/email/templates/team-join';
import { prisma } from '@documenso/prisma';
import { TeamMemberRole } from '@documenso/prisma/client';
import { WEBAPP_BASE_URL } from '../../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
import type { JobDefinition } from '../../client/_internal/job';
const SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION_ID = 'send.team-member-left.email';
const SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
teamId: z.number(),
memberUserId: z.number(),
});
export const SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION = {
id: SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION_ID,
name: 'Send Team Member Left Email',
version: '1.0.0',
trigger: {
name: SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION_ID,
schema: SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const team = await prisma.team.findFirstOrThrow({
where: {
id: payload.teamId,
},
include: {
members: {
where: {
role: {
in: [TeamMemberRole.ADMIN, TeamMemberRole.MANAGER],
},
},
include: {
user: true,
},
},
},
});
const oldMember = await prisma.user.findFirstOrThrow({
where: {
id: payload.memberUserId,
},
});
for (const member of team.members) {
await io.runTask(`send-team-member-left-email--${oldMember.id}_${member.id}`, async () => {
const emailContent = TeamJoinEmailTemplate({
assetBaseUrl: WEBAPP_BASE_URL,
baseUrl: WEBAPP_BASE_URL,
memberName: oldMember.name || '',
memberEmail: oldMember.email,
teamName: team.name,
teamUrl: team.url,
});
await mailer.sendMail({
to: member.user.email,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: `A team member has left ${team.name}`,
html: render(emailContent),
text: render(emailContent, { plainText: true }),
});
});
}
},
} as const satisfies JobDefinition<
typeof SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION_ID,
z.infer<typeof SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION_SCHEMA>
>;

View File

@ -1,7 +1,8 @@
import { updateSubscriptionItemQuantity } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { prisma } from '@documenso/prisma';
import { IS_BILLING_ENABLED } from '../../constants/app';
import { jobs } from '../../jobs/client';
export type AcceptTeamInvitationOptions = {
userId: number;
@ -26,6 +27,11 @@ export const acceptTeamInvitation = async ({ userId, teamId }: AcceptTeamInvitat
team: {
include: {
subscription: true,
members: {
include: {
user: true,
},
},
},
},
},
@ -33,7 +39,7 @@ export const acceptTeamInvitation = async ({ userId, teamId }: AcceptTeamInvitat
const { team } = teamMemberInvite;
await tx.teamMember.create({
const teamMember = await tx.teamMember.create({
data: {
teamId: teamMemberInvite.teamId,
userId: user.id,
@ -60,6 +66,14 @@ export const acceptTeamInvitation = async ({ userId, teamId }: AcceptTeamInvitat
quantity: numberOfSeats,
});
}
await jobs.triggerJob({
name: 'send.team-member-joined.email',
payload: {
teamId: team.id,
memberId: teamMember.id,
},
});
},
{ timeout: 30_000 },
);

View File

@ -0,0 +1,34 @@
import { prisma } from '@documenso/prisma';
export type DeclineTeamInvitationOptions = {
userId: number;
teamId: number;
};
export const declineTeamInvitation = async ({ userId, teamId }: DeclineTeamInvitationOptions) => {
await prisma.$transaction(
async (tx) => {
const user = await tx.user.findFirstOrThrow({
where: {
id: userId,
},
});
const teamMemberInvite = await tx.teamMemberInvite.findFirstOrThrow({
where: {
teamId,
email: user.email,
},
});
await tx.teamMemberInvite.delete({
where: {
id: teamMemberInvite.id,
},
});
// TODO: notify the team owner
},
{ timeout: 30_000 },
);
};

View File

@ -1,7 +1,16 @@
import { createElement } from 'react';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import type { TeamDeleteEmailProps } from '@documenso/email/templates/team-delete';
import { TeamDeleteEmailTemplate } from '@documenso/email/templates/team-delete';
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import { AppError } from '@documenso/lib/errors/app-error';
import { stripe } from '@documenso/lib/server-only/stripe';
import { prisma } from '@documenso/prisma';
import { AppError } from '../../errors/app-error';
import { stripe } from '../stripe';
import { jobs } from '../../jobs/client';
export type DeleteTeamOptions = {
userId: number;
@ -18,6 +27,17 @@ export const deleteTeam = async ({ userId, teamId }: DeleteTeamOptions) => {
},
include: {
subscription: true,
members: {
include: {
user: {
select: {
id: true,
name: true,
email: true,
},
},
},
},
},
});
@ -33,6 +53,22 @@ export const deleteTeam = async ({ userId, teamId }: DeleteTeamOptions) => {
});
}
await jobs.triggerJob({
name: 'send.team-deleted.email',
payload: {
team: {
name: team.name,
url: team.url,
ownerUserId: team.ownerUserId,
},
members: team.members.map((member) => ({
id: member.user.id,
name: member.user.name || '',
email: member.user.email,
})),
},
});
await tx.team.delete({
where: {
id: teamId,
@ -43,3 +79,30 @@ export const deleteTeam = async ({ userId, teamId }: DeleteTeamOptions) => {
{ timeout: 30_000 },
);
};
type SendTeamDeleteEmailOptions = Omit<TeamDeleteEmailProps, 'baseUrl' | 'assetBaseUrl'> & {
email: string;
teamName: string;
};
export const sendTeamDeleteEmail = async ({
email,
...emailTemplateOptions
}: SendTeamDeleteEmailOptions) => {
const template = createElement(TeamDeleteEmailTemplate, {
assetBaseUrl: WEBAPP_BASE_URL,
baseUrl: WEBAPP_BASE_URL,
...emailTemplateOptions,
});
await mailer.sendMail({
to: email,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: `Team "${emailTemplateOptions.teamName}" has been deleted on Documenso`,
html: render(template),
text: render(template, { plainText: true }),
});
};

View File

@ -2,6 +2,8 @@ import { updateSubscriptionItemQuantity } from '@documenso/ee/server-only/stripe
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { prisma } from '@documenso/prisma';
import { jobs } from '../../jobs/client';
export type LeaveTeamOptions = {
/**
* The ID of the user who is leaving the team.
@ -23,12 +25,21 @@ export const leaveTeam = async ({ userId, teamId }: LeaveTeamOptions) => {
ownerUserId: {
not: userId,
},
members: {
some: {
userId,
},
},
},
include: {
subscription: true,
},
});
const leavingUser = await tx.user.findUniqueOrThrow({
where: { id: userId },
});
await tx.teamMember.delete({
where: {
userId_teamId: {
@ -56,6 +67,14 @@ export const leaveTeam = async ({ userId, teamId }: LeaveTeamOptions) => {
quantity: numberOfSeats,
});
}
await jobs.triggerJob({
name: 'send.team-member-left.email',
payload: {
teamId,
memberUserId: leavingUser.id,
},
});
},
{ timeout: 30_000 },
);

View File

@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "TeamMemberInviteStatus" ADD VALUE 'DECLINED';

View File

@ -480,6 +480,7 @@ enum TeamMemberRole {
enum TeamMemberInviteStatus {
ACCEPTED
PENDING
DECLINED
}
model Team {

View File

@ -7,9 +7,6 @@ module.exports = {
content: ['src/**/*.{ts,tsx}'],
theme: {
extend: {
screens: {
print: { raw: 'print' },
},
fontFamily: {
sans: ['var(--font-sans)', ...fontFamily.sans],
signature: ['var(--font-signature)'],
@ -138,6 +135,7 @@ module.exports = {
'3xl': '1920px',
'4xl': '2560px',
'5xl': '3840px',
print: { raw: 'print' },
},
},
},

View File

@ -8,6 +8,7 @@ import { createTeamBillingPortal } from '@documenso/lib/server-only/team/create-
import { createTeamPendingCheckoutSession } from '@documenso/lib/server-only/team/create-team-checkout-session';
import { createTeamEmailVerification } from '@documenso/lib/server-only/team/create-team-email-verification';
import { createTeamMemberInvites } from '@documenso/lib/server-only/team/create-team-member-invites';
import { declineTeamInvitation } from '@documenso/lib/server-only/team/decline-team-invitation';
import { deleteTeam } from '@documenso/lib/server-only/team/delete-team';
import { deleteTeamEmail } from '@documenso/lib/server-only/team/delete-team-email';
import { deleteTeamEmailVerification } from '@documenso/lib/server-only/team/delete-team-email-verification';
@ -42,6 +43,7 @@ import {
ZCreateTeamMemberInvitesMutationSchema,
ZCreateTeamMutationSchema,
ZCreateTeamPendingCheckoutMutationSchema,
ZDeclineTeamInvitationMutationSchema,
ZDeleteTeamEmailMutationSchema,
ZDeleteTeamEmailVerificationMutationSchema,
ZDeleteTeamMemberInvitationsMutationSchema,
@ -82,6 +84,21 @@ export const teamRouter = router({
}
}),
declineTeamInvitation: authenticatedProcedure
.input(ZDeclineTeamInvitationMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
return await declineTeamInvitation({
teamId: input.teamId,
userId: ctx.user.id,
});
} catch (err) {
console.error(err);
throw AppError.parseErrorToTRPCError(err);
}
}),
createBillingPortal: authenticatedProcedure
.input(ZCreateTeamBillingPortalMutationSchema)
.mutation(async ({ input, ctx }) => {

View File

@ -53,6 +53,10 @@ export const ZAcceptTeamInvitationMutationSchema = z.object({
teamId: z.number(),
});
export const ZDeclineTeamInvitationMutationSchema = z.object({
teamId: z.number(),
});
export const ZCreateTeamBillingPortalMutationSchema = z.object({
teamId: z.number(),
});