Merge branch 'main' into feat/sign-redirect

This commit is contained in:
Adithya Krishna
2024-02-08 12:56:42 +05:30
committed by GitHub
55 changed files with 1149 additions and 313 deletions

View File

@ -0,0 +1,205 @@
import { expect, test } from '@playwright/test';
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
import { seedTeam, unseedTeam } from '@documenso/prisma/seed/teams';
import { seedTemplate } from '@documenso/prisma/seed/templates';
import { manualLogin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' });
test('[TEMPLATES]: view templates', async ({ page }) => {
const team = await seedTeam({
createTeamMembers: 1,
});
const owner = team.owner;
const teamMemberUser = team.members[1].user;
// Should only be visible to the owner in personal templates.
await seedTemplate({
title: 'Personal template',
userId: owner.id,
});
// Should be visible to team members.
await seedTemplate({
title: 'Team template 1',
userId: owner.id,
teamId: team.id,
});
// Should be visible to team members.
await seedTemplate({
title: 'Team template 2',
userId: teamMemberUser.id,
teamId: team.id,
});
await manualLogin({
page,
email: owner.email,
redirectPath: '/templates',
});
// Owner should see both team templates.
await page.goto(`${WEBAPP_BASE_URL}/t/${team.url}/templates`);
await expect(page.getByRole('main')).toContainText('Showing 2 results');
// 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 }) => {
const team = await seedTeam({
createTeamMembers: 1,
});
const owner = team.owner;
const teamMemberUser = team.members[1].user;
// Should only be visible to the owner in personal templates.
await seedTemplate({
title: 'Personal template',
userId: owner.id,
});
// Should be visible to team members.
await seedTemplate({
title: 'Team template 1',
userId: owner.id,
teamId: team.id,
});
// Should be visible to team members.
await seedTemplate({
title: 'Team template 2',
userId: teamMemberUser.id,
teamId: team.id,
});
await manualLogin({
page,
email: owner.email,
redirectPath: '/templates',
});
// Owner should be able to delete their personal template.
await page.getByRole('cell', { name: 'Use Template' }).getByRole('button').nth(1).click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Delete' }).click();
await expect(page.getByText('Template deleted').first()).toBeVisible();
// Team member should be able to delete all templates.
await page.goto(`${WEBAPP_BASE_URL}/t/${team.url}/templates`);
for (const template of ['Team template 1', 'Team template 2']) {
await page
.getByRole('row', { name: template })
.getByRole('cell', { name: 'Use Template' })
.getByRole('button')
.nth(1)
.click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Delete' }).click();
await expect(page.getByText('Template deleted').first()).toBeVisible();
}
await unseedTeam(team.url);
});
test('[TEMPLATES]: duplicate template', async ({ page }) => {
const team = await seedTeam({
createTeamMembers: 1,
});
const owner = team.owner;
const teamMemberUser = team.members[1].user;
// Should only be visible to the owner in personal templates.
await seedTemplate({
title: 'Personal template',
userId: owner.id,
});
// Should be visible to team members.
await seedTemplate({
title: 'Team template 1',
userId: teamMemberUser.id,
teamId: team.id,
});
await manualLogin({
page,
email: owner.email,
redirectPath: '/templates',
});
// Duplicate personal template.
await page.getByRole('cell', { name: 'Use Template' }).getByRole('button').nth(1).click();
await page.getByRole('menuitem', { name: 'Duplicate' }).click();
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 page.goto(`${WEBAPP_BASE_URL}/t/${team.url}/templates`);
// Duplicate team template.
await page.getByRole('cell', { name: 'Use Template' }).getByRole('button').nth(1).click();
await page.getByRole('menuitem', { name: 'Duplicate' }).click();
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 }) => {
const team = await seedTeam({
createTeamMembers: 1,
});
const owner = team.owner;
const teamMemberUser = team.members[1].user;
// Should only be visible to the owner in personal templates.
await seedTemplate({
title: 'Personal template',
userId: owner.id,
});
// Should be visible to team members.
await seedTemplate({
title: 'Team template 1',
userId: teamMemberUser.id,
teamId: team.id,
});
await manualLogin({
page,
email: owner.email,
redirectPath: '/templates',
});
// Use personal template.
await page.getByRole('button', { name: 'Use Template' }).click();
await page.waitForURL(/documents/);
await page.getByRole('main').getByRole('link', { name: 'Documents' }).click();
await page.waitForURL('/documents');
await expect(page.getByRole('main')).toContainText('Showing 1 result');
await page.goto(`${WEBAPP_BASE_URL}/t/${team.url}/templates`);
// Use team template.
await page.getByRole('button', { name: 'Use Template' }).click();
await page.waitForURL(/\/t\/.+\/documents/);
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

@ -17,6 +17,7 @@ export const FEATURE_FLAG_POLL_INTERVAL = 30000;
*/
export const LOCAL_FEATURE_FLAGS: Record<string, boolean> = {
app_billing: process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true',
app_teams: true,
marketing_header_single_player_mode: false,
} as const;

View File

@ -1,6 +1,7 @@
import { TeamMemberRole } from '@documenso/prisma/client';
export const TEAM_URL_ROOT_REGEX = new RegExp('^/t/[^/]+$');
export const TEAM_URL_REGEX = new RegExp('^/t/[^/]+');
export const TEAM_MEMBER_ROLE_MAP: Record<keyof typeof TeamMemberRole, string> = {
ADMIN: 'Admin',

View File

@ -10,7 +10,20 @@ export const getFieldsForTemplate = async ({ templateId, userId }: GetFieldsForT
where: {
templateId,
Template: {
userId,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
},
},
},
},
],
},
},
orderBy: {

View File

@ -56,11 +56,7 @@ export const setFieldsForDocument = async ({
});
const removedFields = existingFields.filter(
(existingField) =>
!fields.find(
(field) =>
field.id === existingField.id || field.signerEmail === existingField.Recipient?.email,
),
(existingField) => !fields.find((field) => field.id === existingField.id),
);
const linkedFields = fields

View File

@ -27,7 +27,20 @@ export const setFieldsForTemplate = async ({
const template = await prisma.template.findFirst({
where: {
id: templateId,
userId,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
},
},
},
},
],
},
});

View File

@ -13,7 +13,20 @@ export const getRecipientsForTemplate = async ({
where: {
templateId,
Template: {
userId,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
},
},
},
},
],
},
},
orderBy: {

View File

@ -20,7 +20,20 @@ export const setRecipientsForTemplate = async ({
const template = await prisma.template.findFirst({
where: {
id: templateId,
userId,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
},
},
},
},
],
},
});

View File

@ -11,7 +11,23 @@ export const createDocumentFromTemplate = async ({
userId,
}: CreateDocumentFromTemplateOptions) => {
const template = await prisma.template.findUnique({
where: { id: templateId, userId },
where: {
id: templateId,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
},
},
},
},
],
},
include: {
Recipient: true,
Field: true,
@ -34,6 +50,7 @@ export const createDocumentFromTemplate = async ({
const document = await prisma.document.create({
data: {
userId,
teamId: template.teamId,
title: template.title,
documentDataId: documentData.id,
Recipient: {

View File

@ -1,20 +1,36 @@
import { prisma } from '@documenso/prisma';
import { TCreateTemplateMutationSchema } from '@documenso/trpc/server/template-router/schema';
import type { TCreateTemplateMutationSchema } from '@documenso/trpc/server/template-router/schema';
export type CreateTemplateOptions = TCreateTemplateMutationSchema & {
userId: number;
teamId?: number;
};
export const createTemplate = async ({
title,
userId,
teamId,
templateDocumentDataId,
}: CreateTemplateOptions) => {
if (teamId) {
await prisma.team.findFirstOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
});
}
return await prisma.template.create({
data: {
title,
userId,
templateDocumentDataId,
teamId,
},
});
};

View File

@ -8,5 +8,23 @@ export type DeleteTemplateOptions = {
};
export const deleteTemplate = async ({ id, userId }: DeleteTemplateOptions) => {
return await prisma.template.delete({ where: { id, userId } });
return await prisma.template.delete({
where: {
id,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
},
},
},
},
],
},
});
};

View File

@ -1,14 +1,39 @@
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import type { Prisma } from '@documenso/prisma/client';
import type { TDuplicateTemplateMutationSchema } from '@documenso/trpc/server/template-router/schema';
export type DuplicateTemplateOptions = TDuplicateTemplateMutationSchema & {
userId: number;
};
export const duplicateTemplate = async ({ templateId, userId }: DuplicateTemplateOptions) => {
export const duplicateTemplate = async ({
templateId,
userId,
teamId,
}: DuplicateTemplateOptions) => {
let templateWhereFilter: Prisma.TemplateWhereUniqueInput = {
id: templateId,
userId,
teamId: null,
};
if (teamId !== undefined) {
templateWhereFilter = {
id: templateId,
teamId,
team: {
members: {
some: {
userId,
},
},
},
};
}
const template = await prisma.template.findUnique({
where: { id: templateId, userId },
where: templateWhereFilter,
include: {
Recipient: true,
Field: true,
@ -31,6 +56,7 @@ export const duplicateTemplate = async ({ templateId, userId }: DuplicateTemplat
const duplicatedTemplate = await prisma.template.create({
data: {
userId,
teamId,
title: template.title + ' (copy)',
templateDocumentDataId: documentData.id,
Recipient: {

View File

@ -0,0 +1,56 @@
import { prisma } from '@documenso/prisma';
import type { Prisma } from '@documenso/prisma/client';
export type FindTemplatesOptions = {
userId: number;
teamId?: number;
page: number;
perPage: number;
};
export const findTemplates = async ({
userId,
teamId,
page = 1,
perPage = 10,
}: FindTemplatesOptions) => {
let whereFilter: Prisma.TemplateWhereInput = {
userId,
teamId: null,
};
if (teamId !== undefined) {
whereFilter = {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
};
}
const [templates, count] = await Promise.all([
prisma.template.findMany({
where: whereFilter,
include: {
templateDocumentData: true,
Field: true,
},
skip: Math.max(page - 1, 0) * perPage,
orderBy: {
createdAt: 'desc',
},
}),
prisma.template.count({
where: whereFilter,
}),
]);
return {
templates,
totalPages: Math.ceil(count / perPage),
};
};

View File

@ -1,4 +1,5 @@
import { prisma } from '@documenso/prisma';
import type { Prisma } from '@documenso/prisma/client';
export interface GetTemplateByIdOptions {
id: number;
@ -6,11 +7,26 @@ export interface GetTemplateByIdOptions {
}
export const getTemplateById = async ({ id, userId }: GetTemplateByIdOptions) => {
const whereFilter: Prisma.TemplateWhereInput = {
id,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
},
},
},
},
],
};
return await prisma.template.findFirstOrThrow({
where: {
id,
userId,
},
where: whereFilter,
include: {
templateDocumentData: true,
},

View File

@ -1,35 +0,0 @@
import { prisma } from '@documenso/prisma';
export type GetTemplatesOptions = {
userId: number;
page: number;
perPage: number;
};
export const getTemplates = async ({ userId, page = 1, perPage = 10 }: GetTemplatesOptions) => {
const [templates, count] = await Promise.all([
prisma.template.findMany({
where: {
userId,
},
include: {
templateDocumentData: true,
Field: true,
},
skip: Math.max(page - 1, 0) * perPage,
orderBy: {
createdAt: 'desc',
},
}),
prisma.template.count({
where: {
userId,
},
}),
]);
return {
templates,
totalPages: Math.ceil(count / perPage),
};
};

View File

@ -12,6 +12,10 @@ export const formatDocumentsPath = (teamUrl?: string) => {
return teamUrl ? `/t/${teamUrl}/documents` : '/documents';
};
export const formatTemplatesPath = (teamUrl?: string) => {
return teamUrl ? `/t/${teamUrl}/templates` : '/templates';
};
/**
* Determines whether a team member can execute a given action.
*

View File

@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Template" ADD COLUMN "teamId" INTEGER;
-- AddForeignKey
ALTER TABLE "Template" ADD CONSTRAINT "Template_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -335,7 +335,8 @@ model Team {
owner User @relation(fields: [ownerUserId], references: [id])
subscription Subscription?
document Document[]
document Document[]
templates Template[]
}
model TeamPending {
@ -416,10 +417,12 @@ model Template {
type TemplateType @default(PRIVATE)
title String
userId Int
teamId Int?
templateDocumentDataId String
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
templateDocumentData DocumentData @relation(fields: [templateDocumentDataId], references: [id], onDelete: Cascade)
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
Recipient Recipient[]

View File

@ -0,0 +1,36 @@
import fs from 'node:fs';
import path from 'node:path';
import { prisma } from '..';
import { DocumentDataType } from '../client';
const examplePdf = fs
.readFileSync(path.join(__dirname, '../../../assets/example.pdf'))
.toString('base64');
type SeedTemplateOptions = {
title?: string;
userId: number;
teamId?: number;
};
export const seedTemplate = async (options: SeedTemplateOptions) => {
const { title = 'Untitled', userId, teamId } = options;
const documentData = await prisma.documentData.create({
data: {
type: DocumentDataType.BYTES_64,
data: examplePdf,
initialData: examplePdf,
},
});
return await prisma.template.create({
data: {
title,
templateDocumentDataId: documentData.id,
userId: userId,
teamId,
},
});
};

View File

@ -39,7 +39,7 @@ export const fieldRouter = router({
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to sign this field. Please try again later.',
message: 'We were unable to set this field. Please try again later.',
});
}
}),

View File

@ -33,7 +33,7 @@ export const recipientRouter = router({
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to sign this field. Please try again later.',
message: 'We were unable to set this field. Please try again later.',
});
}
}),
@ -58,7 +58,7 @@ export const recipientRouter = router({
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to sign this field. Please try again later.',
message: 'We were unable to set this field. Please try again later.',
});
}
}),

View File

@ -19,11 +19,12 @@ export const templateRouter = router({
.input(ZCreateTemplateMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
const { title, templateDocumentDataId } = input;
const { teamId, title, templateDocumentDataId } = input;
return await createTemplate({
title,
userId: ctx.user.id,
teamId,
title,
templateDocumentDataId,
});
} catch (err) {
@ -64,11 +65,12 @@ export const templateRouter = router({
.input(ZDuplicateTemplateMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
const { templateId } = input;
const { teamId, templateId } = input;
return await duplicateTemplate({
templateId,
userId: ctx.user.id,
teamId,
templateId,
});
} catch (err) {
console.error(err);
@ -88,7 +90,7 @@ export const templateRouter = router({
const userId = ctx.user.id;
return await deleteTemplate({ id, userId });
return await deleteTemplate({ userId, id });
} catch (err) {
console.error(err);

View File

@ -1,7 +1,8 @@
import { z } from 'zod';
export const ZCreateTemplateMutationSchema = z.object({
title: z.string().min(1),
title: z.string().min(1).trim(),
teamId: z.number().optional(),
templateDocumentDataId: z.string().min(1),
});
@ -11,6 +12,7 @@ export const ZCreateDocumentFromTemplateMutationSchema = z.object({
export const ZDuplicateTemplateMutationSchema = z.object({
templateId: z.number(),
teamId: z.number().optional(),
});
export const ZDeleteTemplateMutationSchema = z.object({

View File

@ -1,5 +1,6 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import type { Field, Recipient } from '@documenso/prisma/client';
@ -10,6 +11,7 @@ import { Input } from '../input';
import { Label } from '../label';
import { useStep } from '../stepper';
import type { TAddTitleFormSchema } from './add-title.types';
import { ZAddTitleFormSchema } from './add-title.types';
import {
DocumentFlowFormContainerActions,
DocumentFlowFormContainerContent,
@ -40,6 +42,7 @@ export const AddTitleFormPartial = ({
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<TAddTitleFormSchema>({
resolver: zodResolver(ZAddTitleFormSchema),
defaultValues: {
title: document.title,
},
@ -71,7 +74,7 @@ export const AddTitleFormPartial = ({
id="title"
className="bg-background my-2"
disabled={isSubmitting}
{...register('title', { required: "Title can't be empty" })}
{...register('title')}
/>
<FormErrorMessage className="mt-2" error={errors.title} />

View File

@ -1,7 +1,7 @@
import { z } from 'zod';
export const ZAddTitleFormSchema = z.object({
title: z.string().min(1),
title: z.string().trim().min(1, { message: "Title can't be empty" }),
});
export type TAddTitleFormSchema = z.infer<typeof ZAddTitleFormSchema>;

View File

@ -3,7 +3,8 @@
import * as React from 'react';
import * as SheetPrimitive from '@radix-ui/react-dialog';
import { VariantProps, cva } from 'class-variance-authority';
import type { VariantProps } from 'class-variance-authority';
import { cva } from 'class-variance-authority';
import { X } from 'lucide-react';
import { cn } from '../lib/utils';
@ -12,7 +13,7 @@ const Sheet = SheetPrimitive.Root;
const SheetTrigger = SheetPrimitive.Trigger;
const portalVariants = cva('fixed inset-0 z-50 flex', {
const portalVariants = cva('fixed inset-0 z-[61] flex', {
variants: {
position: {
top: 'items-start',
@ -42,7 +43,7 @@ const SheetOverlay = React.forwardRef<
>(({ className, children: _children, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
'bg-background/80 data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:fade-in fixed inset-0 z-50 backdrop-blur-sm transition-all duration-100',
'bg-background/80 data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:fade-in fixed inset-0 z-[61] backdrop-blur-sm transition-all duration-100',
className,
)}
{...props}
@ -53,7 +54,7 @@ const SheetOverlay = React.forwardRef<
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
'fixed z-50 scale-100 gap-4 bg-background p-6 opacity-100 shadow-lg border',
'fixed z-[61] scale-100 gap-4 bg-background p-6 opacity-100 shadow-lg border',
{
variants: {
position: {