feat: add team templates (#912)

This commit is contained in:
David Nguyen
2024-02-08 12:33:20 +11:00
committed by GitHub
parent e0889426bb
commit e97b9b4f1c
42 changed files with 831 additions and 272 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

@ -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

@ -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

@ -334,7 +334,8 @@ model Team {
owner User @relation(fields: [ownerUserId], references: [id])
subscription Subscription?
document Document[]
document Document[]
templates Template[]
}
model TeamPending {
@ -415,10 +416,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({