From 963ba13aa682a5f7f1b065788a38cd4e0fb123b7 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 27 Jun 2024 15:44:16 +1000 Subject: [PATCH] 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
Delete template response ```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" } ```
Get template response ```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": "" }, "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" } ] } ```
Get templates response ```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 } ```
## 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. --- .../templates/data-table-action-dropdown.tsx | 1 + .../templates/delete-template-dialog.tsx | 15 ++- packages/api/v1/contract.ts | 40 +++++++ packages/api/v1/implementation.ts | 70 ++++++++++++ packages/api/v1/schema.ts | 107 ++++++++++++++++++ .../server-only/template/delete-template.ts | 33 +++--- .../template/get-template-by-id.ts | 47 +++++--- .../trpc/server/template-router/router.ts | 4 +- .../trpc/server/template-router/schema.ts | 1 + 9 files changed, 287 insertions(+), 31 deletions(-) diff --git a/apps/web/src/app/(dashboard)/templates/data-table-action-dropdown.tsx b/apps/web/src/app/(dashboard)/templates/data-table-action-dropdown.tsx index f728174a6..315c2022b 100644 --- a/apps/web/src/app/(dashboard)/templates/data-table-action-dropdown.tsx +++ b/apps/web/src/app/(dashboard)/templates/data-table-action-dropdown.tsx @@ -97,6 +97,7 @@ export const DataTableActionDropdown = ({ diff --git a/apps/web/src/app/(dashboard)/templates/delete-template-dialog.tsx b/apps/web/src/app/(dashboard)/templates/delete-template-dialog.tsx index b31ad2048..9ded79fa5 100644 --- a/apps/web/src/app/(dashboard)/templates/delete-template-dialog.tsx +++ b/apps/web/src/app/(dashboard)/templates/delete-template-dialog.tsx @@ -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 - diff --git a/packages/api/v1/contract.ts b/packages/api/v1/contract.ts index 577143ead..c6c6b47f5 100644 --- a/packages/api/v1/contract.ts +++ b/packages/api/v1/contract.ts @@ -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', diff --git a/packages/api/v1/implementation.ts b/packages/api/v1/implementation.ts index 18221edb6..1379b447c 100644 --- a/packages/api/v1/implementation.ts +++ b/packages/api/v1/implementation.ts @@ -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; diff --git a/packages/api/v1/schema.ts b/packages/api/v1/schema.ts index 7f82c611e..5ecab08c6 100644 --- a/packages/api/v1/schema.ts +++ b/packages/api/v1/schema.ts @@ -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; + +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), +}); diff --git a/packages/lib/server-only/template/delete-template.ts b/packages/lib/server-only/template/delete-template.ts index c24cc1333..0962b6b9a 100644 --- a/packages/lib/server-only/template/delete-template.ts +++ b/packages/lib/server-only/template/delete-template.ts @@ -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, + }, + }, + }, + }, + ], }, }); }; diff --git a/packages/lib/server-only/template/get-template-by-id.ts b/packages/lib/server-only/template/get-template-by-id.ts index c4295c3c3..fbc8c48f8 100644 --- a/packages/lib/server-only/template/get-template-by-id.ts +++ b/packages/lib/server-only/template/get-template-by-id.ts @@ -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; }; diff --git a/packages/trpc/server/template-router/router.ts b/packages/trpc/server/template-router/router.ts index f708ee2d7..6ef02f247 100644 --- a/packages/trpc/server/template-router/router.ts +++ b/packages/trpc/server/template-router/router.ts @@ -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); diff --git a/packages/trpc/server/template-router/schema.ts b/packages/trpc/server/template-router/schema.ts index 36fde8453..5706eb7bd 100644 --- a/packages/trpc/server/template-router/schema.ts +++ b/packages/trpc/server/template-router/schema.ts @@ -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({