feat: create from template

This commit is contained in:
Mythie
2024-02-20 19:46:18 +11:00
parent 4c5b910a59
commit b9e5905469
8 changed files with 242 additions and 70 deletions

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
@ -72,16 +72,6 @@ export const ApiTokenForm = ({ className }: ApiTokenFormProps) => {
}, },
}); });
useEffect(() => {
if (newlyCreatedToken) {
const timer = setTimeout(() => {
setNewlyCreatedToken('');
}, 30000);
return () => clearTimeout(timer);
}
}, [newlyCreatedToken]);
const copyToken = async (token: string) => { const copyToken = async (token: string) => {
try { try {
const copied = await copy(token); const copied = await copy(token);
@ -166,55 +156,59 @@ export const ApiTokenForm = ({ className }: ApiTokenFormProps) => {
)} )}
/> />
<FormField <div className="flex flex-col gap-4 md:flex-row">
control={form.control} <FormField
name="expirationDate" control={form.control}
render={({ field }) => ( name="expirationDate"
<FormItem className="flex-1"> render={({ field }) => (
<FormLabel className="text-muted-foreground">Token expiration date</FormLabel> <FormItem className="flex-1">
<FormLabel className="text-muted-foreground">Token expiration date</FormLabel>
<div className="flex items-center gap-x-4"> <div className="flex items-center gap-x-4">
<FormControl className="flex-1"> <FormControl className="flex-1">
<Select onValueChange={field.onChange} disabled={noExpirationDate}> <Select onValueChange={field.onChange} disabled={noExpirationDate}>
<SelectTrigger className="w-[180px]"> <SelectTrigger className="w-full">
<SelectValue placeholder="Choose..." /> <SelectValue placeholder="Choose..." />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{Object.entries(EXPIRATION_DATES).map(([key, date]) => ( {Object.entries(EXPIRATION_DATES).map(([key, date]) => (
<SelectItem key={key} value={key}> <SelectItem key={key} value={key}>
{date} {date}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</FormControl>
</div>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="">
<FormLabel className="text-muted-foreground mt-2">Never expire</FormLabel>
<FormControl>
<div className="block md:py-1.5">
<Switch
className="bg-background"
checked={field.value}
onCheckedChange={(val) => {
setNoExpirationDate((prev) => !prev);
field.onChange(val);
}}
/>
</div>
</FormControl> </FormControl>
</div> <FormMessage />
</FormItem>
<FormMessage /> )}
</FormItem> />
)} </div>
/>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex items-center gap-2">
<FormLabel className="text-muted-foreground mt-2">Never expire</FormLabel>
<FormControl>
<Switch
className="bg-background"
checked={field.value}
onCheckedChange={(val) => {
setNoExpirationDate((prev) => !prev);
field.onChange(val);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button <Button
type="submit" type="submit"

View File

@ -5,7 +5,7 @@ import { ApiContractV1 } from '@documenso/api/v1/contract';
import { ApiContractV1Implementation } from '@documenso/api/v1/implementation'; import { ApiContractV1Implementation } from '@documenso/api/v1/implementation';
const nextRouteHandler = createNextRouter(ApiContractV1, ApiContractV1Implementation, { const nextRouteHandler = createNextRouter(ApiContractV1, ApiContractV1Implementation, {
responseValidation: false, responseValidation: true,
}); });
export default async function handler(req: NextApiRequest, res: NextApiResponse) { export default async function handler(req: NextApiRequest, res: NextApiResponse) {

View File

@ -3,6 +3,8 @@ import { initContract } from '@ts-rest/core';
import { import {
ZSendDocumentForSigningMutationSchema as SendDocumentMutationSchema, ZSendDocumentForSigningMutationSchema as SendDocumentMutationSchema,
ZAuthorizationHeadersSchema, ZAuthorizationHeadersSchema,
ZCreateDocumentFromTemplateMutationResponseSchema,
ZCreateDocumentFromTemplateMutationSchema,
ZCreateDocumentMutationResponseSchema, ZCreateDocumentMutationResponseSchema,
ZCreateDocumentMutationSchema, ZCreateDocumentMutationSchema,
ZCreateFieldMutationSchema, ZCreateFieldMutationSchema,
@ -13,6 +15,7 @@ import {
ZGetDocumentsQuerySchema, ZGetDocumentsQuerySchema,
ZSuccessfulDocumentResponseSchema, ZSuccessfulDocumentResponseSchema,
ZSuccessfulFieldResponseSchema, ZSuccessfulFieldResponseSchema,
ZSuccessfulGetDocumentResponseSchema,
ZSuccessfulRecipientResponseSchema, ZSuccessfulRecipientResponseSchema,
ZSuccessfulResponseSchema, ZSuccessfulResponseSchema,
ZSuccessfulSigningResponseSchema, ZSuccessfulSigningResponseSchema,
@ -41,7 +44,7 @@ export const ApiContractV1 = c.router(
method: 'GET', method: 'GET',
path: '/api/v1/documents/:id', path: '/api/v1/documents/:id',
responses: { responses: {
200: ZSuccessfulDocumentResponseSchema, 200: ZSuccessfulGetDocumentResponseSchema,
401: ZUnsuccessfulResponseSchema, 401: ZUnsuccessfulResponseSchema,
404: ZUnsuccessfulResponseSchema, 404: ZUnsuccessfulResponseSchema,
}, },
@ -60,6 +63,18 @@ export const ApiContractV1 = c.router(
summary: 'Upload a new document and get a presigned URL', summary: 'Upload a new document and get a presigned URL',
}, },
createDocumentFromTemplate: {
method: 'POST',
path: '/api/v1/templates/:templateId/create-document',
body: ZCreateDocumentFromTemplateMutationSchema,
responses: {
200: ZCreateDocumentFromTemplateMutationResponseSchema,
401: ZUnsuccessfulResponseSchema,
404: ZUnsuccessfulResponseSchema,
},
summary: 'Upload a new document and get a presigned URL',
},
sendDocument: { sendDocument: {
method: 'POST', method: 'POST',
path: '/api/v1/documents/:id/send', path: '/api/v1/documents/:id/send',

View File

@ -1,11 +1,13 @@
import { createNextRoute } from '@ts-rest/next'; import { createNextRoute } from '@ts-rest/next';
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data'; import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta';
import { createDocument } from '@documenso/lib/server-only/document/create-document'; import { createDocument } from '@documenso/lib/server-only/document/create-document';
import { deleteDocument } from '@documenso/lib/server-only/document/delete-document'; import { deleteDocument } from '@documenso/lib/server-only/document/delete-document';
import { findDocuments } from '@documenso/lib/server-only/document/find-documents'; import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { sendDocument } from '@documenso/lib/server-only/document/send-document'; import { sendDocument } from '@documenso/lib/server-only/document/send-document';
import { updateDocument } from '@documenso/lib/server-only/document/update-document';
import { createField } from '@documenso/lib/server-only/field/create-field'; import { createField } from '@documenso/lib/server-only/field/create-field';
import { deleteField } from '@documenso/lib/server-only/field/delete-field'; import { deleteField } from '@documenso/lib/server-only/field/delete-field';
import { getFieldById } from '@documenso/lib/server-only/field/get-field-by-id'; import { getFieldById } from '@documenso/lib/server-only/field/get-field-by-id';
@ -15,6 +17,7 @@ import { getRecipientById } from '@documenso/lib/server-only/recipient/get-recip
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document'; import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document'; import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
import { updateRecipient } from '@documenso/lib/server-only/recipient/update-recipient'; import { updateRecipient } from '@documenso/lib/server-only/recipient/update-recipient';
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions'; import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
import { DocumentDataType, DocumentStatus, SigningStatus } from '@documenso/prisma/client'; import { DocumentDataType, DocumentStatus, SigningStatus } from '@documenso/prisma/client';
@ -42,10 +45,17 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
try { try {
const document = await getDocumentById({ id: Number(documentId), userId: user.id }); const document = await getDocumentById({ id: Number(documentId), userId: user.id });
const recipients = await getRecipientsForDocument({
documentId: Number(documentId),
userId: user.id,
});
return { return {
status: 200, status: 200,
body: document, body: {
...document,
recipients,
},
}; };
} catch (err) { } catch (err) {
return { return {
@ -124,6 +134,8 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
documentId: document.id, documentId: document.id,
recipients: recipients.map((recipient) => ({ recipients: recipients.map((recipient) => ({
recipientId: recipient.id, recipientId: recipient.id,
name: recipient.name,
email: recipient.email,
token: recipient.token, token: recipient.token,
role: recipient.role, role: recipient.role,
})), })),
@ -139,6 +151,53 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
} }
}), }),
createDocumentFromTemplate: authenticatedMiddleware(async (args, user) => {
const { body, params } = args;
const templateId = Number(params.templateId);
const fileName = body.title.endsWith('.pdf') ? body.title : `${body.title}.pdf`;
const document = await createDocumentFromTemplate({
templateId,
userId: user.id,
recipients: body.recipients,
});
await updateDocument({
documentId: document.id,
userId: user.id,
data: {
title: body.title,
},
});
if (body.meta) {
await upsertDocumentMeta({
documentId: document.id,
userId: user.id,
subject: body.meta.subject,
message: body.meta.message,
dateFormat: body.meta.dateFormat,
timezone: body.meta.timezone,
});
}
return {
status: 200,
body: {
documentId: document.id,
recipients: document.Recipient.map((recipient) => ({
recipientId: recipient.id,
name: recipient.name,
email: recipient.email,
token: recipient.token,
role: recipient.role,
})),
},
};
}),
sendDocument: authenticatedMiddleware(async (args, user) => { sendDocument: authenticatedMiddleware(async (args, user) => {
const { id } = args.params; const { id } = args.params;
@ -461,6 +520,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
}); });
const remappedField = { const remappedField = {
id: field.id,
documentId: field.documentId, documentId: field.documentId,
recipientId: field.recipientId ?? -1, recipientId: field.recipientId ?? -1,
type: field.type, type: field.type,
@ -545,6 +605,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
}); });
const remappedField = { const remappedField = {
id: updatedField.id,
documentId: updatedField.documentId, documentId: updatedField.documentId,
recipientId: updatedField.recipientId ?? -1, recipientId: updatedField.recipientId ?? -1,
type: updatedField.type, type: updatedField.type,
@ -635,6 +696,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
} }
const remappedField = { const remappedField = {
id: deletedField.id,
documentId: deletedField.documentId, documentId: deletedField.documentId,
recipientId: deletedField.recipientId ?? -1, recipientId: deletedField.recipientId ?? -1,
type: deletedField.type, type: deletedField.type,

View File

@ -16,7 +16,10 @@ export const authenticatedMiddleware = <
) => { ) => {
return async (args: T) => { return async (args: T) => {
try { try {
const { authorization: token } = args.req.headers; const { authorization } = args.req.headers;
// Support for both "Authorization: Bearer api_xxx" and "Authorization: api_xxx"
const [token] = (authorization || '').split('Bearer ').filter((s) => s.length > 0);
if (!token) { if (!token) {
throw new Error('Token was not provided for authenticated middleware'); throw new Error('Token was not provided for authenticated middleware');
@ -26,6 +29,7 @@ export const authenticatedMiddleware = <
return await handler(args, user); return await handler(args, user);
} catch (_err) { } catch (_err) {
console.log({ _err });
return { return {
status: 401, status: 401,
body: { body: {

View File

@ -12,8 +12,8 @@ import {
* Documents * Documents
*/ */
export const ZGetDocumentsQuerySchema = z.object({ export const ZGetDocumentsQuerySchema = z.object({
page: z.number().min(1).optional().default(1), page: z.coerce.number().min(1).optional().default(1),
perPage: z.number().min(1).optional().default(1), perPage: z.coerce.number().min(1).optional().default(1),
}); });
export type TGetDocumentsQuerySchema = z.infer<typeof ZGetDocumentsQuerySchema>; export type TGetDocumentsQuerySchema = z.infer<typeof ZGetDocumentsQuerySchema>;
@ -33,6 +33,14 @@ export const ZSuccessfulDocumentResponseSchema = z.object({
completedAt: z.date().nullable(), completedAt: z.date().nullable(),
}); });
export const ZSuccessfulGetDocumentResponseSchema = ZSuccessfulDocumentResponseSchema.extend({
recipients: z.lazy(() => z.array(ZSuccessfulRecipientResponseSchema)),
});
export type TSuccessfulGetDocumentResponseSchema = z.infer<
typeof ZSuccessfulGetDocumentResponseSchema
>;
export type TSuccessfulDocumentResponseSchema = z.infer<typeof ZSuccessfulDocumentResponseSchema>; export type TSuccessfulDocumentResponseSchema = z.infer<typeof ZSuccessfulDocumentResponseSchema>;
export const ZSendDocumentForSigningMutationSchema = null; export const ZSendDocumentForSigningMutationSchema = null;
@ -84,6 +92,48 @@ export type TCreateDocumentMutationResponseSchema = z.infer<
typeof ZCreateDocumentMutationResponseSchema typeof ZCreateDocumentMutationResponseSchema
>; >;
export const ZCreateDocumentFromTemplateMutationSchema = z.object({
title: z.string().min(1),
recipients: z.array(
z.object({
name: z.string().min(1),
email: z.string().email().min(1),
role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER),
}),
),
meta: z
.object({
subject: z.string(),
message: z.string(),
timezone: z.string(),
dateFormat: z.string(),
redirectUrl: z.string(),
})
.partial()
.optional(),
});
export type TCreateDocumentFromTemplateMutationSchema = z.infer<
typeof ZCreateDocumentFromTemplateMutationSchema
>;
export const ZCreateDocumentFromTemplateMutationResponseSchema = z.object({
documentId: z.number(),
recipients: z.array(
z.object({
recipientId: z.number(),
name: z.string(),
email: z.string().email().min(1),
token: z.string(),
role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER),
}),
),
});
export type TCreateDocumentFromTemplateMutationResponseSchema = z.infer<
typeof ZCreateDocumentFromTemplateMutationResponseSchema
>;
export const ZCreateRecipientMutationSchema = z.object({ export const ZCreateRecipientMutationSchema = z.object({
name: z.string().min(1), name: z.string().min(1),
email: z.string().email().min(1), email: z.string().email().min(1),
@ -105,7 +155,9 @@ export type TDeleteRecipientMutationSchema = typeof ZDeleteRecipientMutationSche
export const ZSuccessfulRecipientResponseSchema = z.object({ export const ZSuccessfulRecipientResponseSchema = z.object({
id: z.number(), id: z.number(),
documentId: z.number(), // !: This handles the fact that we have null documentId's for templates
// !: while we won't need the default we must add it to satisfy typescript
documentId: z.number().nullish().default(-1),
email: z.string().email().min(1), email: z.string().email().min(1),
name: z.string(), name: z.string(),
role: z.nativeEnum(RecipientRole), role: z.nativeEnum(RecipientRole),

View File

@ -22,9 +22,14 @@ export const getUserByApiToken = async ({ token }: { token: string }) => {
throw new Error('Invalid token'); throw new Error('Invalid token');
} }
const tokenObject = user.ApiToken.find((apiToken) => apiToken.token === hashedToken); const retrievedToken = user.ApiToken.find((apiToken) => apiToken.token === hashedToken);
if (!tokenObject || new Date(tokenObject.expires) < new Date()) { // This should be impossible but we need to satisfy TypeScript
if (!retrievedToken) {
throw new Error('Invalid token');
}
if (retrievedToken.expires && retrievedToken.expires < new Date()) {
throw new Error('Expired token'); throw new Error('Expired token');
} }

View File

@ -1,14 +1,21 @@
import { nanoid } from '@documenso/lib/universal/id'; import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import type { TCreateDocumentFromTemplateMutationSchema } from '@documenso/trpc/server/template-router/schema'; import type { RecipientRole } from '@documenso/prisma/client';
export type CreateDocumentFromTemplateOptions = TCreateDocumentFromTemplateMutationSchema & { export type CreateDocumentFromTemplateOptions = {
templateId: number;
userId: number; userId: number;
recipients?: {
name?: string;
email: string;
role?: RecipientRole;
}[];
}; };
export const createDocumentFromTemplate = async ({ export const createDocumentFromTemplate = async ({
templateId, templateId,
userId, userId,
recipients,
}: CreateDocumentFromTemplateOptions) => { }: CreateDocumentFromTemplateOptions) => {
const template = await prisma.template.findUnique({ const template = await prisma.template.findUnique({
where: { where: {
@ -63,7 +70,11 @@ export const createDocumentFromTemplate = async ({
}, },
include: { include: {
Recipient: true, Recipient: {
orderBy: {
id: 'asc',
},
},
}, },
}); });
@ -88,5 +99,34 @@ export const createDocumentFromTemplate = async ({
}), }),
}); });
if (recipients && recipients.length > 0) {
document.Recipient = await Promise.all(
recipients.map(async (recipient, index) => {
const existingRecipient = document.Recipient.at(index);
return await prisma.recipient.upsert({
where: {
documentId_email: {
documentId: document.id,
email: existingRecipient?.email ?? recipient.email,
},
},
update: {
name: recipient.name,
email: recipient.email,
role: recipient.role,
},
create: {
documentId: document.id,
email: recipient.email,
name: recipient.name,
role: recipient.role,
token: nanoid(),
},
});
}),
);
}
return document; return document;
}; };