feat: add more template API endpoints (#1198)

## Description

Update the API endpoint to support more actions for templates


## Changes Made

Add the following endpoints for templates:
- Get template
- Get templates
- Delete template

Get template(s) returns associated recipients and fields. 

UI:
- Updated template delete button to have the destructive delete variant

## Testing Performed

Tested endpoints via /api/v1/openapi

Tested deleting templates via UI manually

## Test data


<details>
  <summary>Delete template response</summary>

```json
{
  "id": 32,
  "type": "PRIVATE",
  "title": "documenso-supporter-pledge.pdf",
  "userId": 3,
  "teamId": null,
  "templateDocumentDataId": "clxva9b4h0001rrh7v0wdw97h",
  "createdAt": "2024-06-26T03:35:45.065Z",
  "updatedAt": "2024-06-26T03:35:45.065Z"
}
```
</details>

<details>
  <summary>Get template response</summary>

```json
{
  "id": 28,
  "type": "PRIVATE",
  "title": "blank_long.pdf",
  "userId": 3,
  "teamId": null,
  "templateDocumentDataId": "clxu4vyty0003rrr52ue5ee4d",
  "createdAt": "2024-06-25T08:17:38.418Z",
  "updatedAt": "2024-06-26T03:36:33.890Z",
  "templateMeta": {
    "id": "clxvaacte0004rrh7s2k910nw",
    "subject": "",
    "message": "",
    "timezone": "Australia/Melbourne",
    "dateFormat": "yyyy-MM-dd hh:mm a",
    "templateId": 28,
    "redirectUrl": ""
  },
  "directLink": {
    "token": "tBJHVFR75sC8m6hPfBTZd",
    "enabled": true
  },
  "templateDocumentData": {
    "id": "clxu4vyty0003rrr52ue5ee4d",
    "type": "BYTES_64",
    "data": "<PDF DATA>"
  },
  "Field": [
    {
      "id": 327,
      "recipientId": 357,
      "type": "SIGNATURE",
      "page": 1,
      "positionX": "55.8431952662722",
      "positionY": "21.39588100686499",
      "width": "29.58579881656805",
      "height": "6.864988558352403"
    },
    {
      "id": 328,
      "recipientId": 357,
      "type": "EMAIL",
      "page": 1,
      "positionX": "28.03254437869823",
      "positionY": "72.99771167048056",
      "width": "29.58579881656805",
      "height": "6.864988558352403"
    }
  ],
  "Recipient": [
    {
      "id": 357,
      "email": "direct.link@documenso.com",
      "name": "Direct link recipient",
      "authOptions": {
        "accessAuth": null,
        "actionAuth": null
      },
      "role": "SIGNER"
    },
    {
      "id": 359,
      "email": "example@documenso.com",
      "name": "Example User",
      "authOptions": {
        "accessAuth": null,
        "actionAuth": null
      },
      "role": "SIGNER"
    }
  ]
}
```
</details>


<details>
  <summary>Get templates response</summary>

```json
{
  "templates": [
    {
      "id": 33,
      "type": "PRIVATE",
      "title": "documenso-supporter-pledge.pdf",
      "userId": 3,
      "teamId": null,
      "templateDocumentDataId": "clxva9oaj0003rrh7hwdyg60o",
      "createdAt": "2024-06-26T03:36:02.130Z",
      "updatedAt": "2024-06-26T03:36:02.130Z",
      "directLink": null,
      "Field": [],
      "Recipient": []
    },
    {
      "id": 28,
      "type": "PRIVATE",
      "title": "blank_long.pdf",
      "userId": 3,
      "teamId": null,
      "templateDocumentDataId": "clxu4vyty0003rrr52ue5ee4d",
      "createdAt": "2024-06-25T08:17:38.418Z",
      "updatedAt": "2024-06-26T03:36:33.890Z",
      "directLink": {
        "token": "tBJHVFR75sC8m6hPfBTZd",
        "enabled": true
      },
      "Field": [
        {
          "id": 327,
          "recipientId": 357,
          "type": "SIGNATURE",
          "page": 1,
          "positionX": "55.8431952662722",
          "positionY": "21.39588100686499",
          "width": "29.58579881656805",
          "height": "6.864988558352403"
        },
        {
          "id": 328,
          "recipientId": 357,
          "type": "EMAIL",
          "page": 1,
          "positionX": "28.03254437869823",
          "positionY": "72.99771167048056",
          "width": "29.58579881656805",
          "height": "6.864988558352403"
        }
      ],
      "Recipient": [
        {
          "id": 357,
          "email": "direct.link@documenso.com",
          "name": "Direct link recipient",
          "authOptions": {
            "accessAuth": null,
            "actionAuth": null
          },
          "role": "SIGNER"
        },
        {
          "id": 359,
          "email": "example@documenso.com",
          "name": "Example User",
          "authOptions": {
            "accessAuth": null,
            "actionAuth": null
          },
          "role": "SIGNER"
        }
      ]
    }
  ],
  "totalPages": 2
}
```
</details>

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
  - Added support for team-based template deletion in the dashboard.
- Enhanced API to manage templates, including fetching and deleting
templates by team ID.

- **Bug Fixes**
- Improved error handling for template operations, ensuring better
feedback when templates are not found.

- **Refactor**
- Updated various components and functions to include `teamId` for more
robust template management.

- **Documentation**
- Expanded schema definitions to detail new structures for template and
team interactions.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
David Nguyen
2024-06-27 15:44:16 +10:00
committed by GitHub
parent d1a53544c1
commit 963ba13aa6
9 changed files with 287 additions and 31 deletions

View File

@ -97,6 +97,7 @@ export const DataTableActionDropdown = ({
<DeleteTemplateDialog
id={row.id}
teamId={row.teamId || undefined}
open={isDeleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
/>

View File

@ -14,11 +14,17 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
type DeleteTemplateDialogProps = {
id: number;
teamId?: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
};
export const DeleteTemplateDialog = ({ id, open, onOpenChange }: DeleteTemplateDialogProps) => {
export const DeleteTemplateDialog = ({
id,
teamId,
open,
onOpenChange,
}: DeleteTemplateDialogProps) => {
const router = useRouter();
const { toast } = useToast();
@ -67,7 +73,12 @@ export const DeleteTemplateDialog = ({ id, open, onOpenChange }: DeleteTemplateD
Cancel
</Button>
<Button type="button" loading={isLoading} onClick={async () => deleteTemplate({ id })}>
<Button
type="button"
variant="destructive"
loading={isLoading}
onClick={async () => deleteTemplate({ id, teamId })}
>
Delete
</Button>
</DialogFooter>

View File

@ -15,10 +15,15 @@ import {
ZGenerateDocumentFromTemplateMutationResponseSchema,
ZGenerateDocumentFromTemplateMutationSchema,
ZGetDocumentsQuerySchema,
ZGetTemplatesQuerySchema,
ZNoBodyMutationSchema,
ZSendDocumentForSigningMutationSchema,
ZSuccessfulDeleteTemplateResponseSchema,
ZSuccessfulDocumentResponseSchema,
ZSuccessfulFieldResponseSchema,
ZSuccessfulGetDocumentResponseSchema,
ZSuccessfulGetTemplateResponseSchema,
ZSuccessfulGetTemplatesResponseSchema,
ZSuccessfulRecipientResponseSchema,
ZSuccessfulResponseSchema,
ZSuccessfulSigningResponseSchema,
@ -77,6 +82,41 @@ export const ApiContractV1 = c.router(
summary: 'Upload a new document and get a presigned URL',
},
deleteTemplate: {
method: 'DELETE',
path: '/api/v1/templates/:id',
body: ZNoBodyMutationSchema,
responses: {
200: ZSuccessfulDeleteTemplateResponseSchema,
401: ZUnsuccessfulResponseSchema,
404: ZUnsuccessfulResponseSchema,
},
summary: 'Delete a template',
},
getTemplate: {
method: 'GET',
path: '/api/v1/templates/:id',
responses: {
200: ZSuccessfulGetTemplateResponseSchema,
401: ZUnsuccessfulResponseSchema,
404: ZUnsuccessfulResponseSchema,
},
summary: 'Get a single template',
},
getTemplates: {
method: 'GET',
path: '/api/v1/templates',
query: ZGetTemplatesQuerySchema,
responses: {
200: ZSuccessfulGetTemplatesResponseSchema,
401: ZUnsuccessfulResponseSchema,
404: ZUnsuccessfulResponseSchema,
},
summary: 'Get all templates',
},
createDocumentFromTemplate: {
method: 'POST',
path: '/api/v1/templates/:templateId/create-document',

View File

@ -24,6 +24,9 @@ import { updateRecipient } from '@documenso/lib/server-only/recipient/update-rec
import type { CreateDocumentFromTemplateResponse } from '@documenso/lib/server-only/template/create-document-from-template';
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
import { createDocumentFromTemplateLegacy } from '@documenso/lib/server-only/template/create-document-from-template-legacy';
import { deleteTemplate } from '@documenso/lib/server-only/template/delete-template';
import { findTemplates } from '@documenso/lib/server-only/template/find-templates';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
@ -277,6 +280,73 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
}
}),
deleteTemplate: authenticatedMiddleware(async (args, user, team) => {
const { id: templateId } = args.params;
try {
const deletedTemplate = await deleteTemplate({
id: Number(templateId),
userId: user.id,
teamId: team?.id,
});
return {
status: 200,
body: deletedTemplate,
};
} catch (err) {
return {
status: 404,
body: {
message: 'Template not found',
},
};
}
}),
getTemplate: authenticatedMiddleware(async (args, user, team) => {
const { id: templateId } = args.params;
try {
const template = await getTemplateById({
id: Number(templateId),
userId: user.id,
teamId: team?.id,
});
return {
status: 200,
body: template,
};
} catch (err) {
return AppError.toRestAPIError(err);
}
}),
getTemplates: authenticatedMiddleware(async (args, user, team) => {
const page = Number(args.query.page) || 1;
const perPage = Number(args.query.perPage) || 10;
try {
const { templates, totalPages } = await findTemplates({
page,
perPage,
userId: user.id,
teamId: team?.id,
});
return {
status: 200,
body: {
templates,
totalPages,
},
};
} catch (err) {
return AppError.toRestAPIError(err);
}
}),
createDocumentFromTemplate: authenticatedMiddleware(async (args, user, team) => {
const { body, params } = args;

View File

@ -2,13 +2,17 @@ import { z } from 'zod';
import { ZUrlSchema } from '@documenso/lib/schemas/common';
import {
DocumentDataType,
FieldType,
ReadStatus,
RecipientRole,
SendStatus,
SigningStatus,
TemplateType,
} from '@documenso/prisma/client';
export const ZNoBodyMutationSchema = null;
/**
* Documents
*/
@ -315,3 +319,106 @@ export const ZUnsuccessfulResponseSchema = z.object({
});
export type TUnsuccessfulResponseSchema = z.infer<typeof ZUnsuccessfulResponseSchema>;
export const ZTemplateMetaSchema = z.object({
id: z.string(),
subject: z.string().nullish(),
message: z.string().nullish(),
timezone: z.string().nullish(),
dateFormat: z.string().nullish(),
templateId: z.number(),
redirectUrl: z.string().nullish(),
});
export const ZTemplateSchema = z.object({
id: z.number(),
type: z.nativeEnum(TemplateType),
title: z.string(),
userId: z.number(),
teamId: z.number().nullish(),
templateDocumentDataId: z.string(),
createdAt: z.date(),
updatedAt: z.date(),
});
export const ZRecipientSchema = z.object({
id: z.number(),
documentId: z.number().nullish(),
templateId: z.number().nullish(),
email: z.string().email().min(1),
name: z.string(),
token: z.string(),
documentDeletedAt: z.date().nullish(),
expired: z.date().nullish(),
signedAt: z.date().nullish(),
authOptions: z.unknown(),
role: z.nativeEnum(RecipientRole),
readStatus: z.nativeEnum(ReadStatus),
signingStatus: z.nativeEnum(SigningStatus),
sendStatus: z.nativeEnum(SendStatus),
});
export const ZFieldSchema = z.object({
id: z.number(),
secondaryId: z.string(),
documentId: z.number().nullish(),
templateId: z.number().nullish(),
recipientId: z.number(),
type: z.nativeEnum(FieldType),
page: z.number(),
positionX: z.unknown(),
positionY: z.unknown(),
width: z.unknown(),
height: z.unknown(),
customText: z.string(),
inserted: z.boolean(),
});
export const ZTemplateWithDataSchema = ZTemplateSchema.extend({
templateMeta: ZTemplateMetaSchema.nullish(),
directLink: z
.object({
token: z.string(),
enabled: z.boolean(),
})
.nullable(),
templateDocumentData: z.object({
id: z.string(),
type: z.nativeEnum(DocumentDataType),
data: z.string(),
}),
Field: ZFieldSchema.pick({
id: true,
recipientId: true,
type: true,
page: true,
positionX: true,
positionY: true,
width: true,
height: true,
}).array(),
Recipient: ZRecipientSchema.pick({
id: true,
email: true,
name: true,
authOptions: true,
role: true,
}).array(),
});
export const ZSuccessfulGetTemplateResponseSchema = ZTemplateWithDataSchema;
export const ZSuccessfulDeleteTemplateResponseSchema = ZTemplateSchema;
export const ZSuccessfulGetTemplatesResponseSchema = z.object({
templates: ZTemplateWithDataSchema.omit({
templateDocumentData: true,
templateMeta: true,
}).array(),
totalPages: z.number(),
});
export const ZGetTemplatesQuerySchema = z.object({
page: z.coerce.number().min(1).optional().default(1),
perPage: z.coerce.number().min(1).optional().default(1),
});

View File

@ -5,26 +5,33 @@ import { prisma } from '@documenso/prisma';
export type DeleteTemplateOptions = {
id: number;
userId: number;
teamId?: number;
};
export const deleteTemplate = async ({ id, userId }: DeleteTemplateOptions) => {
export const deleteTemplate = async ({ id, userId, teamId }: DeleteTemplateOptions) => {
return await prisma.template.delete({
where: {
id,
OR: [
{
userId,
},
{
team: {
members: {
some: {
OR:
teamId === undefined
? [
{
userId,
teamId: null,
},
},
},
},
],
]
: [
{
teamId,
team: {
members: {
some: {
userId,
},
},
},
},
],
},
});
};

View File

@ -1,34 +1,53 @@
import { prisma } from '@documenso/prisma';
import type { Prisma } from '@documenso/prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
export interface GetTemplateByIdOptions {
id: number;
userId: number;
teamId?: number;
}
export const getTemplateById = async ({ id, userId }: GetTemplateByIdOptions) => {
export const getTemplateById = async ({ id, userId, teamId }: GetTemplateByIdOptions) => {
const whereFilter: Prisma.TemplateWhereInput = {
id,
OR: [
{
userId,
},
{
team: {
members: {
some: {
OR:
teamId === undefined
? [
{
userId,
teamId: null,
},
},
},
},
],
]
: [
{
teamId,
team: {
members: {
some: {
userId,
},
},
},
},
],
};
return await prisma.template.findFirstOrThrow({
const template = await prisma.template.findFirst({
where: whereFilter,
include: {
directLink: true,
templateDocumentData: true,
templateMeta: true,
Recipient: true,
Field: true,
},
});
if (!template) {
throw new AppError(AppErrorCode.NOT_FOUND, 'Template not found');
}
return template;
};

View File

@ -151,11 +151,11 @@ export const templateRouter = router({
.input(ZDeleteTemplateMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
const { id } = input;
const { id, teamId } = input;
const userId = ctx.user.id;
return await deleteTemplate({ userId, id });
return await deleteTemplate({ userId, id, teamId });
} catch (err) {
console.error(err);

View File

@ -60,6 +60,7 @@ export const ZToggleTemplateDirectLinkMutationSchema = z.object({
export const ZDeleteTemplateMutationSchema = z.object({
id: z.number().min(1),
teamId: z.number().optional(),
});
export const ZUpdateTemplateSettingsMutationSchema = z.object({