mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 08:13:56 +10:00
Merge branch 'main' into fix/layout-shift-on-table
This commit is contained in:
1
packages/api/index.ts
Normal file
1
packages/api/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export {};
|
||||
1
packages/api/next.ts
Normal file
1
packages/api/next.ts
Normal file
@ -0,0 +1 @@
|
||||
export { createNextRouter } from '@ts-rest/next';
|
||||
30
packages/api/package.json
Normal file
30
packages/api/package.json
Normal file
@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@documenso/api",
|
||||
"version": "1.0.0",
|
||||
"main": "./index.ts",
|
||||
"types": "./index.ts",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"clean": "rimraf node_modules"
|
||||
},
|
||||
"files": [
|
||||
"index.ts",
|
||||
"next.ts",
|
||||
"v1/"
|
||||
],
|
||||
"dependencies": {
|
||||
"@documenso/lib": "*",
|
||||
"@documenso/prisma": "*",
|
||||
"@ts-rest/core": "^3.30.5",
|
||||
"@ts-rest/next": "^3.30.5",
|
||||
"@ts-rest/open-api": "^3.33.0",
|
||||
"@types/swagger-ui-react": "^4.18.3",
|
||||
"luxon": "^3.4.0",
|
||||
"superjson": "^1.13.1",
|
||||
"swagger-ui-react": "^5.11.0",
|
||||
"ts-pattern": "^5.0.5",
|
||||
"zod": "^3.22.4"
|
||||
}
|
||||
}
|
||||
8
packages/api/tsconfig.json
Normal file
8
packages/api/tsconfig.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@documenso/tsconfig/react-library.json",
|
||||
"include": ["."],
|
||||
"exclude": ["dist", "build", "node_modules"],
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
}
|
||||
}
|
||||
12
packages/api/v1/api-documentation.tsx
Normal file
12
packages/api/v1/api-documentation.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import SwaggerUI from 'swagger-ui-react';
|
||||
import 'swagger-ui-react/swagger-ui.css';
|
||||
|
||||
import { OpenAPIV1 } from '@documenso/api/v1/openapi';
|
||||
|
||||
export const OpenApiDocsPage = () => {
|
||||
return <SwaggerUI spec={OpenAPIV1} displayOperationId={true} />;
|
||||
};
|
||||
|
||||
export default OpenApiDocsPage;
|
||||
191
packages/api/v1/contract.ts
Normal file
191
packages/api/v1/contract.ts
Normal file
@ -0,0 +1,191 @@
|
||||
import { initContract } from '@ts-rest/core';
|
||||
|
||||
import {
|
||||
ZAuthorizationHeadersSchema,
|
||||
ZCreateDocumentFromTemplateMutationResponseSchema,
|
||||
ZCreateDocumentFromTemplateMutationSchema,
|
||||
ZCreateDocumentMutationResponseSchema,
|
||||
ZCreateDocumentMutationSchema,
|
||||
ZCreateFieldMutationSchema,
|
||||
ZCreateRecipientMutationSchema,
|
||||
ZDeleteDocumentMutationSchema,
|
||||
ZDeleteFieldMutationSchema,
|
||||
ZDeleteRecipientMutationSchema,
|
||||
ZGetDocumentsQuerySchema,
|
||||
ZSendDocumentForSigningMutationSchema,
|
||||
ZSuccessfulDocumentResponseSchema,
|
||||
ZSuccessfulFieldResponseSchema,
|
||||
ZSuccessfulGetDocumentResponseSchema,
|
||||
ZSuccessfulRecipientResponseSchema,
|
||||
ZSuccessfulResponseSchema,
|
||||
ZSuccessfulSigningResponseSchema,
|
||||
ZUnsuccessfulResponseSchema,
|
||||
ZUpdateFieldMutationSchema,
|
||||
ZUpdateRecipientMutationSchema,
|
||||
} from './schema';
|
||||
|
||||
const c = initContract();
|
||||
|
||||
export const ApiContractV1 = c.router(
|
||||
{
|
||||
getDocuments: {
|
||||
method: 'GET',
|
||||
path: '/api/v1/documents',
|
||||
query: ZGetDocumentsQuerySchema,
|
||||
responses: {
|
||||
200: ZSuccessfulResponseSchema,
|
||||
401: ZUnsuccessfulResponseSchema,
|
||||
404: ZUnsuccessfulResponseSchema,
|
||||
},
|
||||
summary: 'Get all documents',
|
||||
},
|
||||
|
||||
getDocument: {
|
||||
method: 'GET',
|
||||
path: '/api/v1/documents/:id',
|
||||
responses: {
|
||||
200: ZSuccessfulGetDocumentResponseSchema,
|
||||
401: ZUnsuccessfulResponseSchema,
|
||||
404: ZUnsuccessfulResponseSchema,
|
||||
},
|
||||
summary: 'Get a single document',
|
||||
},
|
||||
|
||||
createDocument: {
|
||||
method: 'POST',
|
||||
path: '/api/v1/documents',
|
||||
body: ZCreateDocumentMutationSchema,
|
||||
responses: {
|
||||
200: ZCreateDocumentMutationResponseSchema,
|
||||
401: ZUnsuccessfulResponseSchema,
|
||||
404: ZUnsuccessfulResponseSchema,
|
||||
},
|
||||
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: 'Create a new document from an existing template',
|
||||
},
|
||||
|
||||
sendDocument: {
|
||||
method: 'POST',
|
||||
path: '/api/v1/documents/:id/send',
|
||||
body: ZSendDocumentForSigningMutationSchema,
|
||||
responses: {
|
||||
200: ZSuccessfulSigningResponseSchema,
|
||||
400: ZUnsuccessfulResponseSchema,
|
||||
401: ZUnsuccessfulResponseSchema,
|
||||
404: ZUnsuccessfulResponseSchema,
|
||||
500: ZUnsuccessfulResponseSchema,
|
||||
},
|
||||
summary: 'Send a document for signing',
|
||||
},
|
||||
|
||||
deleteDocument: {
|
||||
method: 'DELETE',
|
||||
path: '/api/v1/documents/:id',
|
||||
body: ZDeleteDocumentMutationSchema,
|
||||
responses: {
|
||||
200: ZSuccessfulDocumentResponseSchema,
|
||||
401: ZUnsuccessfulResponseSchema,
|
||||
404: ZUnsuccessfulResponseSchema,
|
||||
},
|
||||
summary: 'Delete a document',
|
||||
},
|
||||
|
||||
createRecipient: {
|
||||
method: 'POST',
|
||||
path: '/api/v1/documents/:id/recipients',
|
||||
body: ZCreateRecipientMutationSchema,
|
||||
responses: {
|
||||
200: ZSuccessfulRecipientResponseSchema,
|
||||
400: ZUnsuccessfulResponseSchema,
|
||||
401: ZUnsuccessfulResponseSchema,
|
||||
404: ZUnsuccessfulResponseSchema,
|
||||
500: ZUnsuccessfulResponseSchema,
|
||||
},
|
||||
summary: 'Create a recipient for a document',
|
||||
},
|
||||
|
||||
updateRecipient: {
|
||||
method: 'PATCH',
|
||||
path: '/api/v1/documents/:id/recipients/:recipientId',
|
||||
body: ZUpdateRecipientMutationSchema,
|
||||
responses: {
|
||||
200: ZSuccessfulRecipientResponseSchema,
|
||||
400: ZUnsuccessfulResponseSchema,
|
||||
401: ZUnsuccessfulResponseSchema,
|
||||
404: ZUnsuccessfulResponseSchema,
|
||||
500: ZUnsuccessfulResponseSchema,
|
||||
},
|
||||
summary: 'Update a recipient for a document',
|
||||
},
|
||||
|
||||
deleteRecipient: {
|
||||
method: 'DELETE',
|
||||
path: '/api/v1/documents/:id/recipients/:recipientId',
|
||||
body: ZDeleteRecipientMutationSchema,
|
||||
responses: {
|
||||
200: ZSuccessfulRecipientResponseSchema,
|
||||
400: ZUnsuccessfulResponseSchema,
|
||||
401: ZUnsuccessfulResponseSchema,
|
||||
404: ZUnsuccessfulResponseSchema,
|
||||
500: ZUnsuccessfulResponseSchema,
|
||||
},
|
||||
summary: 'Delete a recipient from a document',
|
||||
},
|
||||
|
||||
createField: {
|
||||
method: 'POST',
|
||||
path: '/api/v1/documents/:id/fields',
|
||||
body: ZCreateFieldMutationSchema,
|
||||
responses: {
|
||||
200: ZSuccessfulFieldResponseSchema,
|
||||
400: ZUnsuccessfulResponseSchema,
|
||||
401: ZUnsuccessfulResponseSchema,
|
||||
404: ZUnsuccessfulResponseSchema,
|
||||
500: ZUnsuccessfulResponseSchema,
|
||||
},
|
||||
summary: 'Create a field for a document',
|
||||
},
|
||||
|
||||
updateField: {
|
||||
method: 'PATCH',
|
||||
path: '/api/v1/documents/:id/fields/:fieldId',
|
||||
body: ZUpdateFieldMutationSchema,
|
||||
responses: {
|
||||
200: ZSuccessfulFieldResponseSchema,
|
||||
400: ZUnsuccessfulResponseSchema,
|
||||
401: ZUnsuccessfulResponseSchema,
|
||||
404: ZUnsuccessfulResponseSchema,
|
||||
500: ZUnsuccessfulResponseSchema,
|
||||
},
|
||||
summary: 'Update a field for a document',
|
||||
},
|
||||
|
||||
deleteField: {
|
||||
method: 'DELETE',
|
||||
path: '/api/v1/documents/:id/fields/:fieldId',
|
||||
body: ZDeleteFieldMutationSchema,
|
||||
responses: {
|
||||
200: ZSuccessfulFieldResponseSchema,
|
||||
400: ZUnsuccessfulResponseSchema,
|
||||
401: ZUnsuccessfulResponseSchema,
|
||||
404: ZUnsuccessfulResponseSchema,
|
||||
500: ZUnsuccessfulResponseSchema,
|
||||
},
|
||||
summary: 'Delete a field from a document',
|
||||
},
|
||||
},
|
||||
{
|
||||
baseHeaders: ZAuthorizationHeadersSchema,
|
||||
},
|
||||
);
|
||||
59
packages/api/v1/examples/01-create-and-send-document.ts
Normal file
59
packages/api/v1/examples/01-create-and-send-document.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { initClient } from '@ts-rest/core';
|
||||
|
||||
import { ApiContractV1 } from '../contract';
|
||||
|
||||
const main = async () => {
|
||||
const client = initClient(ApiContractV1, {
|
||||
baseUrl: 'http://localhost:3000/api/v1',
|
||||
baseHeaders: {
|
||||
authorization: 'Bearer <my-token>',
|
||||
},
|
||||
});
|
||||
|
||||
const { status, body } = await client.createDocument({
|
||||
body: {
|
||||
title: 'My Document',
|
||||
recipients: [
|
||||
{
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com',
|
||||
role: 'SIGNER',
|
||||
},
|
||||
{
|
||||
name: 'Jane Doe',
|
||||
email: 'jane@example.com',
|
||||
role: 'APPROVER',
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
subject: 'Please sign this document',
|
||||
message: 'Hey {signer.name}, please sign the following document: {document.name}',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (status !== 200) {
|
||||
throw new Error('Failed to create document');
|
||||
}
|
||||
|
||||
const { uploadUrl, documentId } = body;
|
||||
|
||||
await fetch(uploadUrl, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
},
|
||||
body: '<raw-binary-data>',
|
||||
});
|
||||
|
||||
await client.sendDocument({
|
||||
params: {
|
||||
id: documentId.toString(),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
43
packages/api/v1/examples/02-add-a-field.ts
Normal file
43
packages/api/v1/examples/02-add-a-field.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { initClient } from '@ts-rest/core';
|
||||
|
||||
import { ApiContractV1 } from '../contract';
|
||||
|
||||
const main = async () => {
|
||||
const client = initClient(ApiContractV1, {
|
||||
baseUrl: 'http://localhost:3000/api/v1',
|
||||
baseHeaders: {
|
||||
authorization: 'Bearer <my-token>',
|
||||
},
|
||||
});
|
||||
|
||||
const documentId = '1';
|
||||
const recipientId = 1;
|
||||
|
||||
const { status, body } = await client.createField({
|
||||
params: {
|
||||
id: documentId,
|
||||
},
|
||||
body: {
|
||||
type: 'SIGNATURE',
|
||||
pageHeight: 2.5, // percent of page to occupy in height
|
||||
pageWidth: 5, // percent of page to occupy in width
|
||||
pageX: 10, // percent from left
|
||||
pageY: 10, // percent from top
|
||||
pageNumber: 1,
|
||||
recipientId,
|
||||
},
|
||||
});
|
||||
|
||||
if (status !== 200) {
|
||||
throw new Error('Failed to create field');
|
||||
}
|
||||
|
||||
const { id: fieldId } = body;
|
||||
|
||||
console.log(`Field created with id: ${fieldId}`);
|
||||
};
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
39
packages/api/v1/examples/03-update-a-field.ts
Normal file
39
packages/api/v1/examples/03-update-a-field.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { initClient } from '@ts-rest/core';
|
||||
|
||||
import { ApiContractV1 } from '../contract';
|
||||
|
||||
const main = async () => {
|
||||
const client = initClient(ApiContractV1, {
|
||||
baseUrl: 'http://localhost:3000/api/v1',
|
||||
baseHeaders: {
|
||||
authorization: 'Bearer <my-token>',
|
||||
},
|
||||
});
|
||||
|
||||
const documentId = '1';
|
||||
const fieldId = '1';
|
||||
|
||||
const { status } = await client.updateField({
|
||||
params: {
|
||||
id: documentId,
|
||||
fieldId,
|
||||
},
|
||||
body: {
|
||||
type: 'SIGNATURE',
|
||||
pageHeight: 2.5, // percent of page to occupy in height
|
||||
pageWidth: 5, // percent of page to occupy in width
|
||||
pageX: 10, // percent from left
|
||||
pageY: 10, // percent from top
|
||||
pageNumber: 1,
|
||||
},
|
||||
});
|
||||
|
||||
if (status !== 200) {
|
||||
throw new Error('Failed to update field');
|
||||
}
|
||||
};
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
31
packages/api/v1/examples/04-remove-a-field.ts
Normal file
31
packages/api/v1/examples/04-remove-a-field.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { initClient } from '@ts-rest/core';
|
||||
|
||||
import { ApiContractV1 } from '../contract';
|
||||
|
||||
const main = async () => {
|
||||
const client = initClient(ApiContractV1, {
|
||||
baseUrl: 'http://localhost:3000/api/v1',
|
||||
baseHeaders: {
|
||||
authorization: 'Bearer <my-token>',
|
||||
},
|
||||
});
|
||||
|
||||
const documentId = '1';
|
||||
const fieldId = '1';
|
||||
|
||||
const { status } = await client.deleteField({
|
||||
params: {
|
||||
id: documentId,
|
||||
fieldId,
|
||||
},
|
||||
});
|
||||
|
||||
if (status !== 200) {
|
||||
throw new Error('Failed to remove field');
|
||||
}
|
||||
};
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
38
packages/api/v1/examples/05-add-a-recipient.ts
Normal file
38
packages/api/v1/examples/05-add-a-recipient.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { initClient } from '@ts-rest/core';
|
||||
|
||||
import { ApiContractV1 } from '../contract';
|
||||
|
||||
const main = async () => {
|
||||
const client = initClient(ApiContractV1, {
|
||||
baseUrl: 'http://localhost:3000/api/v1',
|
||||
baseHeaders: {
|
||||
authorization: 'Bearer <my-token>',
|
||||
},
|
||||
});
|
||||
|
||||
const documentId = '1';
|
||||
|
||||
const { status, body } = await client.createRecipient({
|
||||
params: {
|
||||
id: documentId,
|
||||
},
|
||||
body: {
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com',
|
||||
role: 'APPROVER',
|
||||
},
|
||||
});
|
||||
|
||||
if (status !== 200) {
|
||||
throw new Error('Failed to add recipient');
|
||||
}
|
||||
|
||||
const { id: recipientId } = body;
|
||||
|
||||
console.log(`Recipient added with id: ${recipientId}`);
|
||||
};
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
34
packages/api/v1/examples/06-update-a-recipient.ts
Normal file
34
packages/api/v1/examples/06-update-a-recipient.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { initClient } from '@ts-rest/core';
|
||||
|
||||
import { ApiContractV1 } from '../contract';
|
||||
|
||||
const main = async () => {
|
||||
const client = initClient(ApiContractV1, {
|
||||
baseUrl: 'http://localhost:3000/api/v1',
|
||||
baseHeaders: {
|
||||
authorization: 'Bearer <my-token>',
|
||||
},
|
||||
});
|
||||
|
||||
const documentId = '1';
|
||||
const recipientId = '1';
|
||||
|
||||
const { status } = await client.updateRecipient({
|
||||
params: {
|
||||
id: documentId,
|
||||
recipientId,
|
||||
},
|
||||
body: {
|
||||
name: 'Johnathon Doe',
|
||||
},
|
||||
});
|
||||
|
||||
if (status !== 200) {
|
||||
throw new Error('Failed to update recipient');
|
||||
}
|
||||
};
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
31
packages/api/v1/examples/07-remove-a-recipient.ts
Normal file
31
packages/api/v1/examples/07-remove-a-recipient.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { initClient } from '@ts-rest/core';
|
||||
|
||||
import { ApiContractV1 } from '../contract';
|
||||
|
||||
const main = async () => {
|
||||
const client = initClient(ApiContractV1, {
|
||||
baseUrl: 'http://localhost:3000/api/v1',
|
||||
baseHeaders: {
|
||||
authorization: 'Bearer <my-token>',
|
||||
},
|
||||
});
|
||||
|
||||
const documentId = '1';
|
||||
const recipientId = '1';
|
||||
|
||||
const { status } = await client.deleteRecipient({
|
||||
params: {
|
||||
id: documentId,
|
||||
recipientId,
|
||||
},
|
||||
});
|
||||
|
||||
if (status !== 200) {
|
||||
throw new Error('Failed to update recipient');
|
||||
}
|
||||
};
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
31
packages/api/v1/examples/08-get-a-document.ts
Normal file
31
packages/api/v1/examples/08-get-a-document.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { initClient } from '@ts-rest/core';
|
||||
|
||||
import { ApiContractV1 } from '../contract';
|
||||
|
||||
const main = async () => {
|
||||
const client = initClient(ApiContractV1, {
|
||||
baseUrl: 'http://localhost:3000/api/v1',
|
||||
baseHeaders: {
|
||||
authorization: 'Bearer <my-token>',
|
||||
},
|
||||
});
|
||||
|
||||
const documentId = '1';
|
||||
|
||||
const { status, body } = await client.getDocument({
|
||||
params: {
|
||||
id: documentId,
|
||||
},
|
||||
});
|
||||
|
||||
if (status !== 200) {
|
||||
throw new Error('Failed to get document');
|
||||
}
|
||||
|
||||
console.log(`Got document with id: ${documentId} and title: ${body.title}`);
|
||||
};
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
37
packages/api/v1/examples/09-paginate-all-documents.ts
Normal file
37
packages/api/v1/examples/09-paginate-all-documents.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { initClient } from '@ts-rest/core';
|
||||
|
||||
import { ApiContractV1 } from '../contract';
|
||||
|
||||
const main = async () => {
|
||||
const client = initClient(ApiContractV1, {
|
||||
baseUrl: 'http://localhost:3000/api/v1',
|
||||
baseHeaders: {
|
||||
authorization: 'Bearer <my-token>',
|
||||
},
|
||||
});
|
||||
|
||||
const page = 1;
|
||||
const perPage = 10;
|
||||
|
||||
const { status, body } = await client.getDocuments({
|
||||
query: {
|
||||
page,
|
||||
perPage,
|
||||
},
|
||||
});
|
||||
|
||||
if (status !== 200) {
|
||||
throw new Error('Failed to get documents');
|
||||
}
|
||||
|
||||
for (const document of body.documents) {
|
||||
console.log(`Got document with id: ${document.id} and title: ${document.title}`);
|
||||
}
|
||||
|
||||
console.log(`Total documents: ${body.totalPages * perPage}`);
|
||||
};
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
800
packages/api/v1/implementation.ts
Normal file
800
packages/api/v1/implementation.ts
Normal file
@ -0,0 +1,800 @@
|
||||
import { createNextRoute } from '@ts-rest/next';
|
||||
|
||||
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
|
||||
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 { deleteDocument } from '@documenso/lib/server-only/document/delete-document';
|
||||
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
|
||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||
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 { deleteField } from '@documenso/lib/server-only/field/delete-field';
|
||||
import { getFieldById } from '@documenso/lib/server-only/field/get-field-by-id';
|
||||
import { updateField } from '@documenso/lib/server-only/field/update-field';
|
||||
import { deleteRecipient } from '@documenso/lib/server-only/recipient/delete-recipient';
|
||||
import { getRecipientById } from '@documenso/lib/server-only/recipient/get-recipient-by-id';
|
||||
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 { updateRecipient } from '@documenso/lib/server-only/recipient/update-recipient';
|
||||
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
|
||||
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
|
||||
import { DocumentDataType, DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
|
||||
import { ApiContractV1 } from './contract';
|
||||
import { authenticatedMiddleware } from './middleware/authenticated';
|
||||
|
||||
export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
||||
getDocuments: authenticatedMiddleware(async (args, user, team) => {
|
||||
const page = Number(args.query.page) || 1;
|
||||
const perPage = Number(args.query.perPage) || 10;
|
||||
|
||||
const { data: documents, totalPages } = await findDocuments({
|
||||
page,
|
||||
perPage,
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
});
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
documents,
|
||||
totalPages,
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
getDocument: authenticatedMiddleware(async (args, user, team) => {
|
||||
const { id: documentId } = args.params;
|
||||
|
||||
try {
|
||||
const document = await getDocumentById({
|
||||
id: Number(documentId),
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
});
|
||||
|
||||
const recipients = await getRecipientsForDocument({
|
||||
documentId: Number(documentId),
|
||||
teamId: team?.id,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
...document,
|
||||
recipients,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
status: 404,
|
||||
body: {
|
||||
message: 'Document not found',
|
||||
},
|
||||
};
|
||||
}
|
||||
}),
|
||||
|
||||
deleteDocument: authenticatedMiddleware(async (args, user, team) => {
|
||||
const { id: documentId } = args.params;
|
||||
|
||||
try {
|
||||
const document = await getDocumentById({
|
||||
id: Number(documentId),
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
return {
|
||||
status: 404,
|
||||
body: {
|
||||
message: 'Document not found',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const deletedDocument = await deleteDocument({
|
||||
id: document.id,
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
});
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: deletedDocument,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
status: 404,
|
||||
body: {
|
||||
message: 'Document not found',
|
||||
},
|
||||
};
|
||||
}
|
||||
}),
|
||||
|
||||
createDocument: authenticatedMiddleware(async (args, user, team) => {
|
||||
const { body } = args;
|
||||
|
||||
try {
|
||||
if (process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT !== 's3') {
|
||||
return {
|
||||
status: 500,
|
||||
body: {
|
||||
message: 'Create document is not available without S3 transport.',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const { remaining } = await getServerLimits({ email: user.email, teamId: team?.id });
|
||||
|
||||
if (remaining.documents <= 0) {
|
||||
return {
|
||||
status: 400,
|
||||
body: {
|
||||
message: 'You have reached the maximum number of documents allowed for this month',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const fileName = body.title.endsWith('.pdf') ? body.title : `${body.title}.pdf`;
|
||||
|
||||
const { url, key } = await getPresignPostUrl(fileName, 'application/pdf');
|
||||
|
||||
const documentData = await createDocumentData({
|
||||
data: key,
|
||||
type: DocumentDataType.S3_PATH,
|
||||
});
|
||||
|
||||
const document = await createDocument({
|
||||
title: body.title,
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
documentDataId: documentData.id,
|
||||
requestMetadata: extractNextApiRequestMetadata(args.req),
|
||||
});
|
||||
|
||||
const recipients = await setRecipientsForDocument({
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
documentId: document.id,
|
||||
recipients: body.recipients,
|
||||
requestMetadata: extractNextApiRequestMetadata(args.req),
|
||||
});
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
uploadUrl: url,
|
||||
documentId: document.id,
|
||||
recipients: recipients.map((recipient) => ({
|
||||
recipientId: recipient.id,
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
token: recipient.token,
|
||||
role: recipient.role,
|
||||
})),
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
status: 404,
|
||||
body: {
|
||||
message: 'An error has occured while uploading the file',
|
||||
},
|
||||
};
|
||||
}
|
||||
}),
|
||||
|
||||
createDocumentFromTemplate: authenticatedMiddleware(async (args, user, team) => {
|
||||
const { body, params } = args;
|
||||
|
||||
const { remaining } = await getServerLimits({ email: user.email, teamId: team?.id });
|
||||
|
||||
if (remaining.documents <= 0) {
|
||||
return {
|
||||
status: 400,
|
||||
body: {
|
||||
message: 'You have reached the maximum number of documents allowed for this month',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const templateId = Number(params.templateId);
|
||||
|
||||
const fileName = body.title.endsWith('.pdf') ? body.title : `${body.title}.pdf`;
|
||||
|
||||
const document = await createDocumentFromTemplate({
|
||||
templateId,
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
recipients: body.recipients,
|
||||
});
|
||||
|
||||
await updateDocument({
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
data: {
|
||||
title: fileName,
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
requestMetadata: extractNextApiRequestMetadata(args.req),
|
||||
});
|
||||
}
|
||||
|
||||
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, team) => {
|
||||
const { id } = args.params;
|
||||
|
||||
const document = await getDocumentById({ id: Number(id), userId: user.id, teamId: team?.id });
|
||||
|
||||
if (!document) {
|
||||
return {
|
||||
status: 404,
|
||||
body: {
|
||||
message: 'Document not found',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (document.status === DocumentStatus.COMPLETED) {
|
||||
return {
|
||||
status: 400,
|
||||
body: {
|
||||
message: 'Document is already complete',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// await setRecipientsForDocument({
|
||||
// userId: user.id,
|
||||
// documentId: Number(id),
|
||||
// recipients: [
|
||||
// {
|
||||
// email: body.signerEmail,
|
||||
// name: body.signerName ?? '',
|
||||
// },
|
||||
// ],
|
||||
// });
|
||||
|
||||
// await setFieldsForDocument({
|
||||
// documentId: Number(id),
|
||||
// userId: user.id,
|
||||
// fields: body.fields.map((field) => ({
|
||||
// signerEmail: body.signerEmail,
|
||||
// type: field.fieldType,
|
||||
// pageNumber: field.pageNumber,
|
||||
// pageX: field.pageX,
|
||||
// pageY: field.pageY,
|
||||
// pageWidth: field.pageWidth,
|
||||
// pageHeight: field.pageHeight,
|
||||
// })),
|
||||
// });
|
||||
|
||||
// if (body.emailBody || body.emailSubject) {
|
||||
// await upsertDocumentMeta({
|
||||
// documentId: Number(id),
|
||||
// subject: body.emailSubject ?? '',
|
||||
// message: body.emailBody ?? '',
|
||||
// });
|
||||
// }
|
||||
|
||||
await sendDocument({
|
||||
documentId: Number(id),
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
requestMetadata: extractNextApiRequestMetadata(args.req),
|
||||
});
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
message: 'Document sent for signing successfully',
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
status: 500,
|
||||
body: {
|
||||
message: 'An error has occured while sending the document for signing',
|
||||
},
|
||||
};
|
||||
}
|
||||
}),
|
||||
|
||||
createRecipient: authenticatedMiddleware(async (args, user, team) => {
|
||||
const { id: documentId } = args.params;
|
||||
const { name, email, role } = args.body;
|
||||
|
||||
const document = await getDocumentById({
|
||||
id: Number(documentId),
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
return {
|
||||
status: 404,
|
||||
body: {
|
||||
message: 'Document not found',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (document.status === DocumentStatus.COMPLETED) {
|
||||
return {
|
||||
status: 400,
|
||||
body: {
|
||||
message: 'Document is already completed',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const recipients = await getRecipientsForDocument({
|
||||
documentId: Number(documentId),
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
});
|
||||
|
||||
const recipientAlreadyExists = recipients.some((recipient) => recipient.email === email);
|
||||
|
||||
if (recipientAlreadyExists) {
|
||||
return {
|
||||
status: 400,
|
||||
body: {
|
||||
message: 'Recipient already exists',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const newRecipients = await setRecipientsForDocument({
|
||||
documentId: Number(documentId),
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
recipients: [
|
||||
...recipients,
|
||||
{
|
||||
email,
|
||||
name,
|
||||
role,
|
||||
},
|
||||
],
|
||||
requestMetadata: extractNextApiRequestMetadata(args.req),
|
||||
});
|
||||
|
||||
const newRecipient = newRecipients.find((recipient) => recipient.email === email);
|
||||
|
||||
if (!newRecipient) {
|
||||
throw new Error('Recipient not found');
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
...newRecipient,
|
||||
documentId: Number(documentId),
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
status: 500,
|
||||
body: {
|
||||
message: 'An error has occured while creating the recipient',
|
||||
},
|
||||
};
|
||||
}
|
||||
}),
|
||||
|
||||
updateRecipient: authenticatedMiddleware(async (args, user, team) => {
|
||||
const { id: documentId, recipientId } = args.params;
|
||||
const { name, email, role } = args.body;
|
||||
|
||||
const document = await getDocumentById({
|
||||
id: Number(documentId),
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
return {
|
||||
status: 404,
|
||||
body: {
|
||||
message: 'Document not found',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (document.status === DocumentStatus.COMPLETED) {
|
||||
return {
|
||||
status: 400,
|
||||
body: {
|
||||
message: 'Document is already completed',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const updatedRecipient = await updateRecipient({
|
||||
documentId: Number(documentId),
|
||||
recipientId: Number(recipientId),
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
email,
|
||||
name,
|
||||
role,
|
||||
requestMetadata: extractNextApiRequestMetadata(args.req),
|
||||
}).catch(() => null);
|
||||
|
||||
if (!updatedRecipient) {
|
||||
return {
|
||||
status: 404,
|
||||
body: {
|
||||
message: 'Recipient not found',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
...updatedRecipient,
|
||||
documentId: Number(documentId),
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
deleteRecipient: authenticatedMiddleware(async (args, user, team) => {
|
||||
const { id: documentId, recipientId } = args.params;
|
||||
|
||||
const document = await getDocumentById({
|
||||
id: Number(documentId),
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
return {
|
||||
status: 404,
|
||||
body: {
|
||||
message: 'Document not found',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (document.status === DocumentStatus.COMPLETED) {
|
||||
return {
|
||||
status: 400,
|
||||
body: {
|
||||
message: 'Document is already completed',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const deletedRecipient = await deleteRecipient({
|
||||
documentId: Number(documentId),
|
||||
recipientId: Number(recipientId),
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
requestMetadata: extractNextApiRequestMetadata(args.req),
|
||||
}).catch(() => null);
|
||||
|
||||
if (!deletedRecipient) {
|
||||
return {
|
||||
status: 400,
|
||||
body: {
|
||||
message: 'Unable to delete recipient',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
...deletedRecipient,
|
||||
documentId: Number(documentId),
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
createField: authenticatedMiddleware(async (args, user, team) => {
|
||||
const { id: documentId } = args.params;
|
||||
const { recipientId, type, pageNumber, pageWidth, pageHeight, pageX, pageY } = args.body;
|
||||
|
||||
const document = await getDocumentById({
|
||||
id: Number(documentId),
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
return {
|
||||
status: 404,
|
||||
body: {
|
||||
message: 'Document not found',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (document.status === DocumentStatus.COMPLETED) {
|
||||
return {
|
||||
status: 400,
|
||||
body: {
|
||||
message: 'Document is already completed',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const recipient = await getRecipientById({
|
||||
id: Number(recipientId),
|
||||
documentId: Number(documentId),
|
||||
}).catch(() => null);
|
||||
|
||||
if (!recipient) {
|
||||
return {
|
||||
status: 404,
|
||||
body: {
|
||||
message: 'Recipient not found',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (recipient.signingStatus === SigningStatus.SIGNED) {
|
||||
return {
|
||||
status: 400,
|
||||
body: {
|
||||
message: 'Recipient has already signed the document',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const field = await createField({
|
||||
documentId: Number(documentId),
|
||||
recipientId: Number(recipientId),
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
type,
|
||||
pageNumber,
|
||||
pageX,
|
||||
pageY,
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
requestMetadata: extractNextApiRequestMetadata(args.req),
|
||||
});
|
||||
|
||||
const remappedField = {
|
||||
id: field.id,
|
||||
documentId: field.documentId,
|
||||
recipientId: field.recipientId ?? -1,
|
||||
type: field.type,
|
||||
pageNumber: field.page,
|
||||
pageX: Number(field.positionX),
|
||||
pageY: Number(field.positionY),
|
||||
pageWidth: Number(field.width),
|
||||
pageHeight: Number(field.height),
|
||||
customText: field.customText,
|
||||
inserted: field.inserted,
|
||||
};
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
...remappedField,
|
||||
documentId: Number(documentId),
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
updateField: authenticatedMiddleware(async (args, user, team) => {
|
||||
const { id: documentId, fieldId } = args.params;
|
||||
const { recipientId, type, pageNumber, pageWidth, pageHeight, pageX, pageY } = args.body;
|
||||
|
||||
const document = await getDocumentById({
|
||||
id: Number(documentId),
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
return {
|
||||
status: 404,
|
||||
body: {
|
||||
message: 'Document not found',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (document.status === DocumentStatus.COMPLETED) {
|
||||
return {
|
||||
status: 400,
|
||||
body: {
|
||||
message: 'Document is already completed',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const recipient = await getRecipientById({
|
||||
id: Number(recipientId),
|
||||
documentId: Number(documentId),
|
||||
}).catch(() => null);
|
||||
|
||||
if (!recipient) {
|
||||
return {
|
||||
status: 404,
|
||||
body: {
|
||||
message: 'Recipient not found',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (recipient.signingStatus === SigningStatus.SIGNED) {
|
||||
return {
|
||||
status: 400,
|
||||
body: {
|
||||
message: 'Recipient has already signed the document',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const updatedField = await updateField({
|
||||
fieldId: Number(fieldId),
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
documentId: Number(documentId),
|
||||
recipientId: recipientId ? Number(recipientId) : undefined,
|
||||
type,
|
||||
pageNumber,
|
||||
pageX,
|
||||
pageY,
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
requestMetadata: extractNextApiRequestMetadata(args.req),
|
||||
});
|
||||
|
||||
const remappedField = {
|
||||
id: updatedField.id,
|
||||
documentId: updatedField.documentId,
|
||||
recipientId: updatedField.recipientId ?? -1,
|
||||
type: updatedField.type,
|
||||
pageNumber: updatedField.page,
|
||||
pageX: Number(updatedField.positionX),
|
||||
pageY: Number(updatedField.positionY),
|
||||
pageWidth: Number(updatedField.width),
|
||||
pageHeight: Number(updatedField.height),
|
||||
customText: updatedField.customText,
|
||||
inserted: updatedField.inserted,
|
||||
};
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
...remappedField,
|
||||
documentId: Number(documentId),
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
deleteField: authenticatedMiddleware(async (args, user, team) => {
|
||||
const { id: documentId, fieldId } = args.params;
|
||||
|
||||
const document = await getDocumentById({
|
||||
id: Number(documentId),
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
return {
|
||||
status: 404,
|
||||
body: {
|
||||
message: 'Document not found',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (document.status === DocumentStatus.COMPLETED) {
|
||||
return {
|
||||
status: 400,
|
||||
body: {
|
||||
message: 'Document is already completed',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const field = await getFieldById({
|
||||
fieldId: Number(fieldId),
|
||||
documentId: Number(documentId),
|
||||
}).catch(() => null);
|
||||
|
||||
if (!field) {
|
||||
return {
|
||||
status: 404,
|
||||
body: {
|
||||
message: 'Field not found',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const recipient = await getRecipientById({
|
||||
id: Number(field.recipientId),
|
||||
documentId: Number(documentId),
|
||||
}).catch(() => null);
|
||||
|
||||
if (recipient?.signingStatus === SigningStatus.SIGNED) {
|
||||
return {
|
||||
status: 400,
|
||||
body: {
|
||||
message: 'Recipient has already signed the document',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const deletedField = await deleteField({
|
||||
documentId: Number(documentId),
|
||||
fieldId: Number(fieldId),
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
requestMetadata: extractNextApiRequestMetadata(args.req),
|
||||
}).catch(() => null);
|
||||
|
||||
if (!deletedField) {
|
||||
return {
|
||||
status: 400,
|
||||
body: {
|
||||
message: 'Unable to delete field',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const remappedField = {
|
||||
id: deletedField.id,
|
||||
documentId: deletedField.documentId,
|
||||
recipientId: deletedField.recipientId ?? -1,
|
||||
type: deletedField.type,
|
||||
pageNumber: deletedField.page,
|
||||
pageX: Number(deletedField.positionX),
|
||||
pageY: Number(deletedField.positionY),
|
||||
pageWidth: Number(deletedField.width),
|
||||
pageHeight: Number(deletedField.height),
|
||||
customText: deletedField.customText,
|
||||
inserted: deletedField.inserted,
|
||||
};
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
...remappedField,
|
||||
documentId: Number(documentId),
|
||||
},
|
||||
};
|
||||
}),
|
||||
});
|
||||
41
packages/api/v1/middleware/authenticated.ts
Normal file
41
packages/api/v1/middleware/authenticated.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import type { NextApiRequest } from 'next';
|
||||
|
||||
import { getApiTokenByToken } from '@documenso/lib/server-only/public-api/get-api-token-by-token';
|
||||
import type { Team, User } from '@documenso/prisma/client';
|
||||
|
||||
export const authenticatedMiddleware = <
|
||||
T extends {
|
||||
req: NextApiRequest;
|
||||
},
|
||||
R extends {
|
||||
status: number;
|
||||
body: unknown;
|
||||
},
|
||||
>(
|
||||
handler: (args: T, user: User, team?: Team | null) => Promise<R>,
|
||||
) => {
|
||||
return async (args: T) => {
|
||||
try {
|
||||
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) {
|
||||
throw new Error('Token was not provided for authenticated middleware');
|
||||
}
|
||||
|
||||
const apiToken = await getApiTokenByToken({ token });
|
||||
|
||||
return await handler(args, apiToken.user, apiToken.team);
|
||||
} catch (_err) {
|
||||
console.log({ _err });
|
||||
return {
|
||||
status: 401,
|
||||
body: {
|
||||
message: 'Unauthorized',
|
||||
},
|
||||
} as const;
|
||||
}
|
||||
};
|
||||
};
|
||||
17
packages/api/v1/openapi.ts
Normal file
17
packages/api/v1/openapi.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { generateOpenApi } from '@ts-rest/open-api';
|
||||
|
||||
import { ApiContractV1 } from './contract';
|
||||
|
||||
export const OpenAPIV1 = generateOpenApi(
|
||||
ApiContractV1,
|
||||
{
|
||||
info: {
|
||||
title: 'Documenso API',
|
||||
version: '1.0.0',
|
||||
description: 'The Documenso API for retrieving, creating, updating and deleting documents.',
|
||||
},
|
||||
},
|
||||
{
|
||||
setOperationId: true,
|
||||
},
|
||||
);
|
||||
241
packages/api/v1/schema.ts
Normal file
241
packages/api/v1/schema.ts
Normal file
@ -0,0 +1,241 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
FieldType,
|
||||
ReadStatus,
|
||||
RecipientRole,
|
||||
SendStatus,
|
||||
SigningStatus,
|
||||
} from '@documenso/prisma/client';
|
||||
|
||||
/**
|
||||
* Documents
|
||||
*/
|
||||
export const ZGetDocumentsQuerySchema = z.object({
|
||||
page: z.coerce.number().min(1).optional().default(1),
|
||||
perPage: z.coerce.number().min(1).optional().default(1),
|
||||
});
|
||||
|
||||
export type TGetDocumentsQuerySchema = z.infer<typeof ZGetDocumentsQuerySchema>;
|
||||
|
||||
export const ZDeleteDocumentMutationSchema = null;
|
||||
|
||||
export type TDeleteDocumentMutationSchema = typeof ZDeleteDocumentMutationSchema;
|
||||
|
||||
export const ZSuccessfulDocumentResponseSchema = z.object({
|
||||
id: z.number(),
|
||||
userId: z.number(),
|
||||
teamId: z.number().nullish(),
|
||||
title: z.string(),
|
||||
status: z.string(),
|
||||
documentDataId: z.string(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
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 const ZSendDocumentForSigningMutationSchema = null;
|
||||
|
||||
export type TSendDocumentForSigningMutationSchema = typeof ZSendDocumentForSigningMutationSchema;
|
||||
|
||||
export const ZUploadDocumentSuccessfulSchema = z.object({
|
||||
url: z.string(),
|
||||
key: z.string(),
|
||||
});
|
||||
|
||||
export type TUploadDocumentSuccessfulSchema = z.infer<typeof ZUploadDocumentSuccessfulSchema>;
|
||||
|
||||
export const ZCreateDocumentMutationSchema = 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(),
|
||||
});
|
||||
|
||||
export type TCreateDocumentMutationSchema = z.infer<typeof ZCreateDocumentMutationSchema>;
|
||||
|
||||
export const ZCreateDocumentMutationResponseSchema = z.object({
|
||||
uploadUrl: z.string().min(1),
|
||||
documentId: z.number(),
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
recipientId: z.number(),
|
||||
token: z.string(),
|
||||
role: z.nativeEnum(RecipientRole),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export type TCreateDocumentMutationResponseSchema = z.infer<
|
||||
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({
|
||||
name: z.string().min(1),
|
||||
email: z.string().email().min(1),
|
||||
role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER),
|
||||
});
|
||||
|
||||
/**
|
||||
* Recipients
|
||||
*/
|
||||
export type TCreateRecipientMutationSchema = z.infer<typeof ZCreateRecipientMutationSchema>;
|
||||
|
||||
export const ZUpdateRecipientMutationSchema = ZCreateRecipientMutationSchema.partial();
|
||||
|
||||
export type TUpdateRecipientMutationSchema = z.infer<typeof ZUpdateRecipientMutationSchema>;
|
||||
|
||||
export const ZDeleteRecipientMutationSchema = null;
|
||||
|
||||
export type TDeleteRecipientMutationSchema = typeof ZDeleteRecipientMutationSchema;
|
||||
|
||||
export const ZSuccessfulRecipientResponseSchema = z.object({
|
||||
id: 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),
|
||||
name: z.string(),
|
||||
role: z.nativeEnum(RecipientRole),
|
||||
token: z.string(),
|
||||
// !: Not used for now
|
||||
// expired: z.string(),
|
||||
signedAt: z.date().nullable(),
|
||||
readStatus: z.nativeEnum(ReadStatus),
|
||||
signingStatus: z.nativeEnum(SigningStatus),
|
||||
sendStatus: z.nativeEnum(SendStatus),
|
||||
});
|
||||
|
||||
export type TSuccessfulRecipientResponseSchema = z.infer<typeof ZSuccessfulRecipientResponseSchema>;
|
||||
|
||||
/**
|
||||
* Fields
|
||||
*/
|
||||
export const ZCreateFieldMutationSchema = z.object({
|
||||
recipientId: z.number(),
|
||||
type: z.nativeEnum(FieldType),
|
||||
pageNumber: z.number(),
|
||||
pageX: z.number(),
|
||||
pageY: z.number(),
|
||||
pageWidth: z.number(),
|
||||
pageHeight: z.number(),
|
||||
});
|
||||
|
||||
export type TCreateFieldMutationSchema = z.infer<typeof ZCreateFieldMutationSchema>;
|
||||
|
||||
export const ZUpdateFieldMutationSchema = ZCreateFieldMutationSchema.partial();
|
||||
|
||||
export type TUpdateFieldMutationSchema = z.infer<typeof ZUpdateFieldMutationSchema>;
|
||||
|
||||
export const ZDeleteFieldMutationSchema = null;
|
||||
|
||||
export type TDeleteFieldMutationSchema = typeof ZDeleteFieldMutationSchema;
|
||||
|
||||
export const ZSuccessfulFieldResponseSchema = z.object({
|
||||
id: z.number(),
|
||||
documentId: z.number(),
|
||||
recipientId: z.number(),
|
||||
type: z.nativeEnum(FieldType),
|
||||
pageNumber: z.number(),
|
||||
pageX: z.number(),
|
||||
pageY: z.number(),
|
||||
pageWidth: z.number(),
|
||||
pageHeight: z.number(),
|
||||
customText: z.string(),
|
||||
inserted: z.boolean(),
|
||||
});
|
||||
|
||||
export type TSuccessfulFieldResponseSchema = z.infer<typeof ZSuccessfulFieldResponseSchema>;
|
||||
|
||||
export const ZSuccessfulResponseSchema = z.object({
|
||||
documents: ZSuccessfulDocumentResponseSchema.array(),
|
||||
totalPages: z.number(),
|
||||
});
|
||||
|
||||
export type TSuccessfulResponseSchema = z.infer<typeof ZSuccessfulResponseSchema>;
|
||||
|
||||
export const ZSuccessfulSigningResponseSchema = z.object({
|
||||
message: z.string(),
|
||||
});
|
||||
|
||||
export type TSuccessfulSigningResponseSchema = z.infer<typeof ZSuccessfulSigningResponseSchema>;
|
||||
|
||||
/**
|
||||
* General
|
||||
*/
|
||||
export const ZAuthorizationHeadersSchema = z.object({
|
||||
authorization: z.string(),
|
||||
});
|
||||
|
||||
export type TAuthorizationHeadersSchema = z.infer<typeof ZAuthorizationHeadersSchema>;
|
||||
|
||||
export const ZUnsuccessfulResponseSchema = z.object({
|
||||
message: z.string(),
|
||||
});
|
||||
|
||||
export type TUnsuccessfulResponseSchema = z.infer<typeof ZUnsuccessfulResponseSchema>;
|
||||
@ -34,6 +34,7 @@ export const manualLogin = async ({
|
||||
};
|
||||
|
||||
export const manualSignout = async ({ page }: ManualLoginOptions) => {
|
||||
await page.waitForTimeout(1000);
|
||||
await page.getByTestId('menu-switcher').click();
|
||||
await page.getByRole('menuitem', { name: 'Sign Out' }).click();
|
||||
await page.waitForURL(`${WEBAPP_BASE_URL}/signin`);
|
||||
|
||||
@ -15,7 +15,7 @@ test('[PR-713]: should see sent documents', async ({ page }) => {
|
||||
|
||||
await page.keyboard.press('Meta+K');
|
||||
|
||||
await page.getByPlaceholder('Type a command or search...').fill('sent');
|
||||
await page.getByPlaceholder('Type a command or search...').first().fill('sent');
|
||||
await expect(page.getByRole('option', { name: '[713] Document - Sent' })).toBeVisible();
|
||||
});
|
||||
|
||||
@ -32,7 +32,7 @@ test('[PR-713]: should see received documents', async ({ page }) => {
|
||||
|
||||
await page.keyboard.press('Meta+K');
|
||||
|
||||
await page.getByPlaceholder('Type a command or search...').fill('received');
|
||||
await page.getByPlaceholder('Type a command or search...').first().fill('received');
|
||||
await expect(page.getByRole('option', { name: '[713] Document - Received' })).toBeVisible();
|
||||
});
|
||||
|
||||
@ -49,6 +49,6 @@ test('[PR-713]: should be able to search by recipient', async ({ page }) => {
|
||||
|
||||
await page.keyboard.press('Meta+K');
|
||||
|
||||
await page.getByPlaceholder('Type a command or search...').fill(recipient.email);
|
||||
await page.getByPlaceholder('Type a command or search...').first().fill(recipient.email);
|
||||
await expect(page.getByRole('option', { name: '[713] Document - Sent' })).toBeVisible();
|
||||
});
|
||||
|
||||
@ -107,6 +107,8 @@ test('[TEMPLATES]: delete template', async ({ page }) => {
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
await expect(page.getByText('Template deleted').first()).toBeVisible();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
await unseedTeam(team.url);
|
||||
@ -187,15 +189,18 @@ test('[TEMPLATES]: use template', async ({ page }) => {
|
||||
|
||||
// Use personal template.
|
||||
await page.getByRole('button', { name: 'Use Template' }).click();
|
||||
await page.getByRole('button', { name: 'Create Document' }).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`);
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Use team template.
|
||||
await page.getByRole('button', { name: 'Use Template' }).click();
|
||||
await page.getByRole('button', { name: 'Create Document' }).click();
|
||||
await page.waitForURL(/\/t\/.+\/documents/);
|
||||
await page.getByRole('main').getByRole('link', { name: 'Documents' }).click();
|
||||
await page.waitForURL(`/t/${team.url}/documents`);
|
||||
|
||||
@ -1,20 +1,19 @@
|
||||
import { type Page, expect, test } from '@playwright/test';
|
||||
|
||||
import { deleteUser } from '@documenso/lib/server-only/user/delete-user';
|
||||
import {
|
||||
extractUserVerificationToken,
|
||||
seedUser,
|
||||
unseedUser,
|
||||
unseedUserByEmail,
|
||||
} from '@documenso/prisma/seed/users';
|
||||
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
/*
|
||||
Using them sequentially so the 2nd test
|
||||
uses the details from the 1st (registration) test
|
||||
*/
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
const username = 'Test User';
|
||||
const email = 'test-user@auth-flow.documenso.com';
|
||||
const password = 'Password123#';
|
||||
|
||||
test('user can sign up with email and password', async ({ page }: { page: Page }) => {
|
||||
const username = 'Test User';
|
||||
const email = `test-user-${Date.now()}@auth-flow.documenso.com`;
|
||||
const password = 'Password123#';
|
||||
|
||||
await page.goto('/signup');
|
||||
await page.getByLabel('Name').fill(username);
|
||||
await page.getByLabel('Email').fill(email);
|
||||
@ -30,26 +29,37 @@ test('user can sign up with email and password', async ({ page }: { page: Page }
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Sign Up', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Next', exact: true }).click();
|
||||
await page.getByLabel('Public profile username').fill('username-123');
|
||||
|
||||
await page.getByRole('button', { name: 'Complete', exact: true }).click();
|
||||
|
||||
await page.waitForURL('/unverified-account');
|
||||
|
||||
const { token } = await extractUserVerificationToken(email);
|
||||
|
||||
await page.goto(`/verify-email/${token}`);
|
||||
|
||||
await expect(page.getByRole('heading')).toContainText('Email Confirmed!');
|
||||
|
||||
await page.getByRole('link', { name: 'Go back home' }).click();
|
||||
|
||||
await page.waitForURL('/documents');
|
||||
|
||||
await expect(page).toHaveURL('/documents');
|
||||
await unseedUserByEmail(email);
|
||||
});
|
||||
|
||||
test('user can login with user and password', async ({ page }: { page: Page }) => {
|
||||
const user = await seedUser();
|
||||
|
||||
await page.goto('/signin');
|
||||
await page.getByLabel('Email').fill(email);
|
||||
await page.getByLabel('Password', { exact: true }).fill(password);
|
||||
await page.getByLabel('Email').fill(user.email);
|
||||
await page.getByLabel('Password', { exact: true }).fill('password');
|
||||
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||
|
||||
await page.waitForURL('/documents');
|
||||
await expect(page).toHaveURL('/documents');
|
||||
});
|
||||
|
||||
test.afterAll('Teardown', async () => {
|
||||
try {
|
||||
await deleteUser({ email });
|
||||
} catch (e) {
|
||||
throw new Error(`Error deleting user: ${e}`);
|
||||
}
|
||||
await unseedUser(user.id);
|
||||
});
|
||||
|
||||
25
packages/app-tests/e2e/test-delete-user.spec.ts
Normal file
25
packages/app-tests/e2e/test-delete-user.spec.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
||||
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { manualLogin } from './fixtures/authentication';
|
||||
|
||||
test('delete user', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
|
||||
await manualLogin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: '/settings',
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Delete Account' }).click();
|
||||
await page.getByRole('button', { name: 'Confirm Deletion' }).click();
|
||||
|
||||
await page.waitForURL(`${WEBAPP_BASE_URL}/signin`);
|
||||
|
||||
// Verify that the user no longer exists in the database
|
||||
await expect(getUserByEmail({ email: user.email })).rejects.toThrow();
|
||||
});
|
||||
BIN
packages/assets/images/background-lw-2.png
Normal file
BIN
packages/assets/images/background-lw-2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 104 KiB |
BIN
packages/assets/images/community-cards.png
Normal file
BIN
packages/assets/images/community-cards.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.5 MiB |
BIN
packages/assets/images/profile-claim-teaser.png
Normal file
BIN
packages/assets/images/profile-claim-teaser.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 88 KiB |
BIN
packages/assets/images/timur.png
Normal file
BIN
packages/assets/images/timur.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
@ -33,7 +33,7 @@ type TransferStripeSubscriptionOptions = {
|
||||
* Will create a new subscription for the new owner and cancel the old one.
|
||||
*
|
||||
* Returns the subscription that should be associated with the team, null if
|
||||
* no subscription is needed (for community plan).
|
||||
* no subscription is needed (for early adopter plan).
|
||||
*/
|
||||
export const transferTeamSubscription = async ({
|
||||
user,
|
||||
|
||||
19
packages/lib/client-only/download-file.ts
Normal file
19
packages/lib/client-only/download-file.ts
Normal file
@ -0,0 +1,19 @@
|
||||
export type DownloadFileOptions = {
|
||||
filename: string;
|
||||
data: Blob;
|
||||
};
|
||||
|
||||
export const downloadFile = ({ filename, data }: DownloadFileOptions) => {
|
||||
if (typeof window === 'undefined') {
|
||||
throw new Error('downloadFile can only be called in browser environments');
|
||||
}
|
||||
|
||||
const link = window.document.createElement('a');
|
||||
|
||||
link.href = window.URL.createObjectURL(data);
|
||||
link.download = filename;
|
||||
|
||||
link.click();
|
||||
|
||||
window.URL.revokeObjectURL(link.href);
|
||||
};
|
||||
@ -1,6 +1,7 @@
|
||||
import type { DocumentData } from '@documenso/prisma/client';
|
||||
|
||||
import { getFile } from '../universal/upload/get-file';
|
||||
import { downloadFile } from './download-file';
|
||||
|
||||
type DownloadPDFProps = {
|
||||
documentData: DocumentData;
|
||||
@ -14,16 +15,10 @@ export const downloadPDF = async ({ documentData, fileName }: DownloadPDFProps)
|
||||
type: 'application/pdf',
|
||||
});
|
||||
|
||||
const link = window.document.createElement('a');
|
||||
const baseTitle = (fileName ?? 'document').replace(/\.pdf$/, '');
|
||||
|
||||
const [baseTitle] = fileName?.includes('.pdf')
|
||||
? fileName.split('.pdf')
|
||||
: [fileName ?? 'document'];
|
||||
|
||||
link.href = window.URL.createObjectURL(blob);
|
||||
link.download = `${baseTitle}_signed.pdf`;
|
||||
|
||||
link.click();
|
||||
|
||||
window.URL.revokeObjectURL(link.href);
|
||||
downloadFile({
|
||||
filename: `${baseTitle}.pdf`,
|
||||
data: blob,
|
||||
});
|
||||
};
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { TCreateOrGetShareLinkMutationSchema } from '@documenso/trpc/server/share-link-router/schema';
|
||||
import type { TCreateOrGetShareLinkMutationSchema } from '@documenso/trpc/server/share-link-router/schema';
|
||||
|
||||
import { useCopyToClipboard } from './use-copy-to-clipboard';
|
||||
|
||||
|
||||
19
packages/lib/constants/document-audit-logs.ts
Normal file
19
packages/lib/constants/document-audit-logs.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { DOCUMENT_EMAIL_TYPE } from '../types/document-audit-logs';
|
||||
|
||||
export const DOCUMENT_AUDIT_LOG_EMAIL_FORMAT = {
|
||||
[DOCUMENT_EMAIL_TYPE.SIGNING_REQUEST]: {
|
||||
description: 'Signing request',
|
||||
},
|
||||
[DOCUMENT_EMAIL_TYPE.VIEW_REQUEST]: {
|
||||
description: 'Viewing request',
|
||||
},
|
||||
[DOCUMENT_EMAIL_TYPE.APPROVE_REQUEST]: {
|
||||
description: 'Approval request',
|
||||
},
|
||||
[DOCUMENT_EMAIL_TYPE.CC]: {
|
||||
description: 'CC',
|
||||
},
|
||||
[DOCUMENT_EMAIL_TYPE.DOCUMENT_COMPLETED]: {
|
||||
description: 'Document completed',
|
||||
},
|
||||
} satisfies Record<keyof typeof DOCUMENT_EMAIL_TYPE, unknown>;
|
||||
@ -22,7 +22,9 @@ export const FEATURE_FLAG_POLL_INTERVAL = 30000;
|
||||
*/
|
||||
export const LOCAL_FEATURE_FLAGS: Record<string, boolean> = {
|
||||
app_billing: NEXT_PUBLIC_FEATURE_BILLING_ENABLED() === 'true',
|
||||
app_document_page_view_history_sheet: false,
|
||||
marketing_header_single_player_mode: false,
|
||||
marketing_profiles_announcement_bar: true,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
|
||||
@ -1,29 +1,31 @@
|
||||
import { RecipientRole } from '@documenso/prisma/client';
|
||||
|
||||
export const RECIPIENT_ROLES_DESCRIPTION: {
|
||||
[key in RecipientRole]: { actionVerb: string; progressiveVerb: string; roleName: string };
|
||||
} = {
|
||||
export const RECIPIENT_ROLES_DESCRIPTION = {
|
||||
[RecipientRole.APPROVER]: {
|
||||
actionVerb: 'Approve',
|
||||
actioned: 'Approved',
|
||||
progressiveVerb: 'Approving',
|
||||
roleName: 'Approver',
|
||||
},
|
||||
[RecipientRole.CC]: {
|
||||
actionVerb: 'CC',
|
||||
actioned: `CC'd`,
|
||||
progressiveVerb: 'CC',
|
||||
roleName: 'CC',
|
||||
roleName: 'Cc',
|
||||
},
|
||||
[RecipientRole.SIGNER]: {
|
||||
actionVerb: 'Sign',
|
||||
actioned: 'Signed',
|
||||
progressiveVerb: 'Signing',
|
||||
roleName: 'Signer',
|
||||
},
|
||||
[RecipientRole.VIEWER]: {
|
||||
actionVerb: 'View',
|
||||
actioned: 'Viewed',
|
||||
progressiveVerb: 'Viewing',
|
||||
roleName: 'Viewer',
|
||||
},
|
||||
};
|
||||
} satisfies Record<keyof typeof RecipientRole, unknown>;
|
||||
|
||||
export const RECIPIENT_ROLE_TO_EMAIL_TYPE = {
|
||||
[RecipientRole.SIGNER]: 'SIGNING_REQUEST',
|
||||
|
||||
@ -1,5 +1,11 @@
|
||||
import { Duration } from 'luxon';
|
||||
|
||||
export const ONE_SECOND = 1000;
|
||||
export const ONE_MINUTE = ONE_SECOND * 60;
|
||||
export const ONE_HOUR = ONE_MINUTE * 60;
|
||||
export const ONE_DAY = ONE_HOUR * 24;
|
||||
export const ONE_WEEK = ONE_DAY * 7;
|
||||
export const ONE_MONTH = Duration.fromObject({ months: 1 });
|
||||
export const THREE_MONTHS = Duration.fromObject({ months: 3 });
|
||||
export const SIX_MONTHS = Duration.fromObject({ months: 6 });
|
||||
export const ONE_YEAR = Duration.fromObject({ years: 1 });
|
||||
|
||||
@ -18,6 +18,8 @@ export enum AppErrorCode {
|
||||
'RETRY_EXCEPTION' = 'RetryException',
|
||||
'SCHEMA_FAILED' = 'SchemaFailed',
|
||||
'TOO_MANY_REQUESTS' = 'TooManyRequests',
|
||||
'PROFILE_URL_TAKEN' = 'ProfileUrlTaken',
|
||||
'PREMIUM_PROFILE_URL' = 'PremiumProfileUrl',
|
||||
}
|
||||
|
||||
const genericErrorCodeToTrpcErrorCodeMap: Record<string, TRPCError['code']> = {
|
||||
@ -32,6 +34,8 @@ const genericErrorCodeToTrpcErrorCodeMap: Record<string, TRPCError['code']> = {
|
||||
[AppErrorCode.RETRY_EXCEPTION]: 'INTERNAL_SERVER_ERROR',
|
||||
[AppErrorCode.SCHEMA_FAILED]: 'INTERNAL_SERVER_ERROR',
|
||||
[AppErrorCode.TOO_MANY_REQUESTS]: 'TOO_MANY_REQUESTS',
|
||||
[AppErrorCode.PROFILE_URL_TAKEN]: 'BAD_REQUEST',
|
||||
[AppErrorCode.PREMIUM_PROFILE_URL]: 'BAD_REQUEST',
|
||||
};
|
||||
|
||||
export const ZAppErrorJsonSchema = z.object({
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
/// <reference types="../types/next-auth.d.ts" />
|
||||
import { PrismaAdapter } from '@next-auth/prisma-adapter';
|
||||
import { compare } from 'bcrypt';
|
||||
import { compare } from '@node-rs/bcrypt';
|
||||
import { DateTime } from 'luxon';
|
||||
import type { AuthOptions, Session, User } from 'next-auth';
|
||||
import type { JWT } from 'next-auth/jwt';
|
||||
|
||||
@ -32,7 +32,7 @@
|
||||
"@sindresorhus/slugify": "^2.2.1",
|
||||
"@upstash/redis": "^1.20.6",
|
||||
"@vvo/tzdb": "^6.117.0",
|
||||
"bcrypt": "^5.1.0",
|
||||
"@node-rs/bcrypt": "^1.10.0",
|
||||
"luxon": "^3.4.0",
|
||||
"nanoid": "^4.0.2",
|
||||
"next": "14.0.3",
|
||||
@ -46,7 +46,6 @@
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^5.0.0",
|
||||
"@types/luxon": "^3.3.1"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { compare } from 'bcrypt';
|
||||
import { compare } from '@node-rs/bcrypt';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { User } from '@documenso/prisma/client';
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { compare } from '@node-rs/bcrypt';
|
||||
import { base32 } from '@scure/base';
|
||||
import { compare } from 'bcrypt';
|
||||
import crypto from 'crypto';
|
||||
import { createTOTPKeyURI } from 'oslo/otp';
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { User } from '@documenso/prisma/client';
|
||||
import type { User } from '@documenso/prisma/client';
|
||||
|
||||
import { ErrorCode } from '../../next-auth/error-codes';
|
||||
import { verifyTwoFactorAuthenticationToken } from './verify-2fa-token';
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { base32 } from '@scure/base';
|
||||
import { TOTPController } from 'oslo/otp';
|
||||
|
||||
import { User } from '@documenso/prisma/client';
|
||||
import type { User } from '@documenso/prisma/client';
|
||||
|
||||
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
|
||||
import { symmetricDecrypt } from '../../universal/crypto';
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { Prisma } from '@documenso/prisma/client';
|
||||
import type { Prisma } from '@documenso/prisma/client';
|
||||
|
||||
export interface FindDocumentsOptions {
|
||||
term?: string;
|
||||
|
||||
26
packages/lib/server-only/admin/get-entire-document.ts
Normal file
26
packages/lib/server-only/admin/get-entire-document.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type GetEntireDocumentOptions = {
|
||||
id: number;
|
||||
};
|
||||
|
||||
export const getEntireDocument = async ({ id }: GetEntireDocumentOptions) => {
|
||||
const document = await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
include: {
|
||||
Recipient: {
|
||||
include: {
|
||||
Field: {
|
||||
include: {
|
||||
Signature: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return document;
|
||||
};
|
||||
30
packages/lib/server-only/admin/update-recipient.ts
Normal file
30
packages/lib/server-only/admin/update-recipient.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { SigningStatus } from '@documenso/prisma/client';
|
||||
|
||||
export type UpdateRecipientOptions = {
|
||||
id: number;
|
||||
name: string | undefined;
|
||||
email: string | undefined;
|
||||
};
|
||||
|
||||
export const updateRecipient = async ({ id, name, email }: UpdateRecipientOptions) => {
|
||||
const recipient = await prisma.recipient.findFirstOrThrow({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
|
||||
if (recipient.signingStatus === SigningStatus.SIGNED) {
|
||||
throw new Error('Cannot update a recipient that has already signed.');
|
||||
}
|
||||
|
||||
return await prisma.recipient.update({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
data: {
|
||||
name,
|
||||
email,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -1,5 +1,5 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { Role } from '@documenso/prisma/client';
|
||||
import type { Role } from '@documenso/prisma/client';
|
||||
|
||||
export type UpdateUserOptions = {
|
||||
id: number;
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { compareSync as bcryptCompareSync, hashSync as bcryptHashSync } from 'bcrypt';
|
||||
import { compareSync as bcryptCompareSync, hashSync as bcryptHashSync } from '@node-rs/bcrypt';
|
||||
import crypto from 'crypto';
|
||||
|
||||
import { SALT_ROUNDS } from '../../constants/auth';
|
||||
|
||||
@ -12,3 +13,7 @@ export const hashSync = (password: string) => {
|
||||
export const compareSync = (password: string, hash: string) => {
|
||||
return bcryptCompareSync(password, hash);
|
||||
};
|
||||
|
||||
export const hashString = (input: string) => {
|
||||
return crypto.createHash('sha512').update(input).digest('hex');
|
||||
};
|
||||
|
||||
12
packages/lib/server-only/crypto/sign.ts
Normal file
12
packages/lib/server-only/crypto/sign.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { hashString } from '../auth/hash';
|
||||
import { encryptSecondaryData } from './encrypt';
|
||||
|
||||
export const sign = (data: unknown) => {
|
||||
const stringified = JSON.stringify(data);
|
||||
|
||||
const hashed = hashString(stringified);
|
||||
|
||||
const signature = encryptSecondaryData({ data: hashed });
|
||||
|
||||
return signature;
|
||||
};
|
||||
12
packages/lib/server-only/crypto/verify.ts
Normal file
12
packages/lib/server-only/crypto/verify.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { hashString } from '../auth/hash';
|
||||
import { decryptSecondaryData } from './decrypt';
|
||||
|
||||
export const verify = (data: unknown, signature: string) => {
|
||||
const stringified = JSON.stringify(data);
|
||||
|
||||
const hashed = hashString(stringified);
|
||||
|
||||
const decrypted = decryptSecondaryData(signature);
|
||||
|
||||
return decrypted === hashed;
|
||||
};
|
||||
@ -89,17 +89,21 @@ export const upsertDocumentMeta = async ({
|
||||
},
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED,
|
||||
documentId,
|
||||
user,
|
||||
requestMetadata,
|
||||
data: {
|
||||
changes: diffDocumentMetaChanges(originalDocumentMeta ?? {}, upsertedDocumentMeta),
|
||||
},
|
||||
}),
|
||||
});
|
||||
const changes = diffDocumentMetaChanges(originalDocumentMeta ?? {}, upsertedDocumentMeta);
|
||||
|
||||
if (changes.length > 0) {
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED,
|
||||
documentId,
|
||||
user,
|
||||
requestMetadata,
|
||||
data: {
|
||||
changes: diffDocumentMetaChanges(originalDocumentMeta ?? {}, upsertedDocumentMeta),
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return upsertedDocumentMeta;
|
||||
});
|
||||
|
||||
@ -5,7 +5,9 @@ import type { RequestMetadata } from '@documenso/lib/universal/extract-request-m
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
import { WebhookTriggerEvents } from '@documenso/prisma/client';
|
||||
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
import { sealDocument } from './seal-document';
|
||||
import { sendPendingEmail } from './send-pending-email';
|
||||
|
||||
@ -15,14 +17,8 @@ export type CompleteDocumentWithTokenOptions = {
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
|
||||
export const completeDocumentWithToken = async ({
|
||||
token,
|
||||
documentId,
|
||||
requestMetadata,
|
||||
}: CompleteDocumentWithTokenOptions) => {
|
||||
'use server';
|
||||
|
||||
const document = await prisma.document.findFirstOrThrow({
|
||||
const getDocument = async ({ token, documentId }: CompleteDocumentWithTokenOptions) => {
|
||||
return await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
id: documentId,
|
||||
Recipient: {
|
||||
@ -39,6 +35,16 @@ export const completeDocumentWithToken = async ({
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const completeDocumentWithToken = async ({
|
||||
token,
|
||||
documentId,
|
||||
requestMetadata,
|
||||
}: CompleteDocumentWithTokenOptions) => {
|
||||
'use server';
|
||||
|
||||
const document = await getDocument({ token, documentId });
|
||||
|
||||
if (document.status === DocumentStatus.COMPLETED) {
|
||||
throw new Error(`Document ${document.id} has already been completed`);
|
||||
@ -124,4 +130,13 @@ export const completeDocumentWithToken = async ({
|
||||
if (documents.count > 0) {
|
||||
await sealDocument({ documentId: document.id, requestMetadata });
|
||||
}
|
||||
|
||||
const updatedDocument = await getDocument({ token, documentId });
|
||||
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_SIGNED,
|
||||
data: updatedDocument,
|
||||
userId: updatedDocument.userId,
|
||||
teamId: updatedDocument.teamId ?? undefined,
|
||||
});
|
||||
};
|
||||
|
||||
@ -5,6 +5,9 @@ import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-log
|
||||
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { WebhookTriggerEvents } from '@documenso/prisma/client';
|
||||
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
|
||||
export type CreateDocumentOptions = {
|
||||
title: string;
|
||||
@ -63,6 +66,13 @@ export const createDocument = async ({
|
||||
}),
|
||||
});
|
||||
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_CREATED,
|
||||
data: document,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
return document;
|
||||
});
|
||||
};
|
||||
|
||||
@ -10,80 +10,127 @@ import { DocumentStatus } from '@documenso/prisma/client';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
|
||||
export type DeleteDocumentOptions = {
|
||||
id: number;
|
||||
userId: number;
|
||||
status: DocumentStatus;
|
||||
teamId?: number;
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
|
||||
export const deleteDocument = async ({ id, userId, status }: DeleteDocumentOptions) => {
|
||||
export const deleteDocument = async ({
|
||||
id,
|
||||
userId,
|
||||
teamId,
|
||||
requestMetadata,
|
||||
}: DeleteDocumentOptions) => {
|
||||
const document = await prisma.document.findUnique({
|
||||
where: {
|
||||
id,
|
||||
...(teamId
|
||||
? {
|
||||
team: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
userId,
|
||||
teamId: null,
|
||||
}),
|
||||
},
|
||||
include: {
|
||||
Recipient: true,
|
||||
documentMeta: true,
|
||||
User: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
throw new Error('Document not found');
|
||||
}
|
||||
|
||||
const { status, User: user } = document;
|
||||
|
||||
// if the document is a draft, hard-delete
|
||||
if (status === DocumentStatus.DRAFT) {
|
||||
return await prisma.document.delete({ where: { id, userId, status: DocumentStatus.DRAFT } });
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
// Currently redundant since deleting a document will delete the audit logs.
|
||||
// However may be useful if we disassociate audit lgos and documents if required.
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
documentId: id,
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
|
||||
user,
|
||||
requestMetadata,
|
||||
data: {
|
||||
type: 'HARD',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return await tx.document.delete({ where: { id, status: DocumentStatus.DRAFT } });
|
||||
});
|
||||
}
|
||||
|
||||
// if the document is pending, send cancellation emails to all recipients
|
||||
if (status === DocumentStatus.PENDING) {
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
if (status === DocumentStatus.PENDING && document.Recipient.length > 0) {
|
||||
await Promise.all(
|
||||
document.Recipient.map(async (recipient) => {
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
|
||||
const document = await prisma.document.findUnique({
|
||||
where: {
|
||||
id,
|
||||
status,
|
||||
userId,
|
||||
},
|
||||
include: {
|
||||
Recipient: true,
|
||||
documentMeta: true,
|
||||
},
|
||||
});
|
||||
const template = createElement(DocumentCancelTemplate, {
|
||||
documentName: document.title,
|
||||
inviterName: user.name || undefined,
|
||||
inviterEmail: user.email,
|
||||
assetBaseUrl,
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
throw new Error('Document not found');
|
||||
}
|
||||
|
||||
if (document.Recipient.length > 0) {
|
||||
await Promise.all(
|
||||
document.Recipient.map(async (recipient) => {
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
|
||||
const template = createElement(DocumentCancelTemplate, {
|
||||
documentName: document.title,
|
||||
inviterName: user.name || undefined,
|
||||
inviterEmail: user.email,
|
||||
assetBaseUrl,
|
||||
});
|
||||
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: recipient.email,
|
||||
name: recipient.name,
|
||||
},
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: 'Document Cancelled',
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: recipient.email,
|
||||
name: recipient.name,
|
||||
},
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: 'Document Cancelled',
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// If the document is not a draft, only soft-delete.
|
||||
return await prisma.document.update({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
data: {
|
||||
deletedAt: new Date().toISOString(),
|
||||
},
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
documentId: id,
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
|
||||
user,
|
||||
requestMetadata,
|
||||
data: {
|
||||
type: 'SOFT',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return await tx.document.update({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
data: {
|
||||
deletedAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
115
packages/lib/server-only/document/find-document-audit-logs.ts
Normal file
115
packages/lib/server-only/document/find-document-audit-logs.ts
Normal file
@ -0,0 +1,115 @@
|
||||
import type { FindResultSet } from '@documenso/lib/types/find-result-set';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { DocumentAuditLog } from '@documenso/prisma/client';
|
||||
import type { Prisma } from '@documenso/prisma/client';
|
||||
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
import { parseDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
|
||||
export interface FindDocumentAuditLogsOptions {
|
||||
userId: number;
|
||||
documentId: number;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
orderBy?: {
|
||||
column: keyof DocumentAuditLog;
|
||||
direction: 'asc' | 'desc';
|
||||
};
|
||||
cursor?: string;
|
||||
filterForRecentActivity?: boolean;
|
||||
}
|
||||
|
||||
export const findDocumentAuditLogs = async ({
|
||||
userId,
|
||||
documentId,
|
||||
page = 1,
|
||||
perPage = 30,
|
||||
orderBy,
|
||||
cursor,
|
||||
filterForRecentActivity,
|
||||
}: FindDocumentAuditLogsOptions) => {
|
||||
const orderByColumn = orderBy?.column ?? 'createdAt';
|
||||
const orderByDirection = orderBy?.direction ?? 'desc';
|
||||
|
||||
await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
id: documentId,
|
||||
OR: [
|
||||
{
|
||||
userId,
|
||||
},
|
||||
{
|
||||
team: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const whereClause: Prisma.DocumentAuditLogWhereInput = {
|
||||
documentId,
|
||||
};
|
||||
|
||||
// Filter events down to what we consider recent activity.
|
||||
if (filterForRecentActivity) {
|
||||
whereClause.OR = [
|
||||
{
|
||||
type: {
|
||||
in: [
|
||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED,
|
||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED,
|
||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
|
||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
|
||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
|
||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT,
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
||||
data: {
|
||||
path: ['isResending'],
|
||||
equals: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const [data, count] = await Promise.all([
|
||||
prisma.documentAuditLog.findMany({
|
||||
where: whereClause,
|
||||
skip: Math.max(page - 1, 0) * perPage,
|
||||
take: perPage + 1,
|
||||
orderBy: {
|
||||
[orderByColumn]: orderByDirection,
|
||||
},
|
||||
cursor: cursor ? { id: cursor } : undefined,
|
||||
}),
|
||||
prisma.documentAuditLog.count({
|
||||
where: whereClause,
|
||||
}),
|
||||
]);
|
||||
|
||||
let nextCursor: string | undefined = undefined;
|
||||
|
||||
const parsedData = data.map((auditLog) => parseDocumentAuditLogData(auditLog));
|
||||
|
||||
if (parsedData.length > perPage) {
|
||||
const nextItem = parsedData.pop();
|
||||
nextCursor = nextItem!.id;
|
||||
}
|
||||
|
||||
return {
|
||||
data: parsedData,
|
||||
count,
|
||||
currentPage: Math.max(page, 1),
|
||||
perPage,
|
||||
totalPages: Math.ceil(count / perPage),
|
||||
nextCursor,
|
||||
} satisfies FindResultSet<typeof parsedData> & { nextCursor?: string };
|
||||
};
|
||||
@ -21,6 +21,19 @@ export const getDocumentById = async ({ id, userId, teamId }: GetDocumentByIdOpt
|
||||
include: {
|
||||
documentData: true,
|
||||
documentMeta: true,
|
||||
User: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
url: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@ -70,6 +70,6 @@ export const getDocumentAndRecipientByToken = async ({
|
||||
|
||||
return {
|
||||
...result,
|
||||
Recipient: result.Recipient[0],
|
||||
Recipient: result.Recipient,
|
||||
};
|
||||
};
|
||||
|
||||
@ -16,9 +16,8 @@ import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||
import type { Prisma } from '@documenso/prisma/client';
|
||||
|
||||
import { getDocumentWhereInput } from './get-document-by-id';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { getDocumentWhereInput } from './get-document-by-id';
|
||||
|
||||
export type ResendDocumentOptions = {
|
||||
documentId: number;
|
||||
@ -111,40 +110,43 @@ export const resendDocument = async ({
|
||||
|
||||
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: email,
|
||||
name,
|
||||
},
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: customEmail?.subject
|
||||
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
|
||||
: `Please ${actionVerb.toLowerCase()} this document`,
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
||||
documentId: document.id,
|
||||
user,
|
||||
requestMetadata,
|
||||
data: {
|
||||
emailType: recipientEmailType,
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
recipientRole: recipient.role,
|
||||
recipientId: recipient.id,
|
||||
isResending: true,
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: email,
|
||||
name,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: customEmail?.subject
|
||||
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
|
||||
: `Please ${actionVerb.toLowerCase()} this document`,
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
||||
documentId: document.id,
|
||||
user,
|
||||
requestMetadata,
|
||||
data: {
|
||||
emailType: recipientEmailType,
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
recipientRole: recipient.role,
|
||||
recipientId: recipient.id,
|
||||
isResending: true,
|
||||
},
|
||||
}),
|
||||
});
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
@ -2,30 +2,34 @@
|
||||
|
||||
import { nanoid } from 'nanoid';
|
||||
import path from 'node:path';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { PDFDocument, PDFSignature, rectangle } from 'pdf-lib';
|
||||
|
||||
import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||
import { WebhookTriggerEvents } from '@documenso/prisma/client';
|
||||
import { signPdf } from '@documenso/signing';
|
||||
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { getFile } from '../../universal/upload/get-file';
|
||||
import { putFile } from '../../universal/upload/put-file';
|
||||
import { insertFieldInPDF } from '../pdf/insert-field-in-pdf';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
import { sendCompletedEmail } from './send-completed-email';
|
||||
|
||||
export type SealDocumentOptions = {
|
||||
documentId: number;
|
||||
sendEmail?: boolean;
|
||||
isResealing?: boolean;
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
|
||||
export const sealDocument = async ({
|
||||
documentId,
|
||||
sendEmail = true,
|
||||
isResealing = false,
|
||||
requestMetadata,
|
||||
}: SealDocumentOptions) => {
|
||||
'use server';
|
||||
@ -36,6 +40,7 @@ export const sealDocument = async ({
|
||||
},
|
||||
include: {
|
||||
documentData: true,
|
||||
Recipient: true,
|
||||
},
|
||||
});
|
||||
|
||||
@ -75,11 +80,43 @@ export const sealDocument = async ({
|
||||
throw new Error(`Document ${document.id} has unsigned fields`);
|
||||
}
|
||||
|
||||
if (isResealing) {
|
||||
// If we're resealing we want to use the initial data for the document
|
||||
// so we aren't placing fields on top of eachother.
|
||||
documentData.data = documentData.initialData;
|
||||
}
|
||||
|
||||
// !: Need to write the fields onto the document as a hard copy
|
||||
const pdfData = await getFile(documentData);
|
||||
|
||||
const doc = await PDFDocument.load(pdfData);
|
||||
|
||||
const form = doc.getForm();
|
||||
|
||||
// Remove old signatures
|
||||
for (const field of form.getFields()) {
|
||||
if (field instanceof PDFSignature) {
|
||||
field.acroField.getWidgets().forEach((widget) => {
|
||||
widget.ensureAP();
|
||||
|
||||
try {
|
||||
widget.getNormalAppearance();
|
||||
} catch (e) {
|
||||
const { context } = widget.dict;
|
||||
|
||||
const xobj = context.formXObject([rectangle(0, 0, 0, 0)]);
|
||||
|
||||
const streamRef = context.register(xobj);
|
||||
|
||||
widget.setNormalAppearance(streamRef);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Flatten the form to stop annotation layers from appearing above documenso fields
|
||||
form.flatten();
|
||||
|
||||
for (const field of fields) {
|
||||
await insertFieldInPDF(doc, field);
|
||||
}
|
||||
@ -131,7 +168,14 @@ export const sealDocument = async ({
|
||||
});
|
||||
});
|
||||
|
||||
if (sendEmail) {
|
||||
if (sendEmail && !isResealing) {
|
||||
await sendCompletedEmail({ documentId, requestMetadata });
|
||||
}
|
||||
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_COMPLETED,
|
||||
data: document,
|
||||
userId: document.userId,
|
||||
teamId: document.teamId ?? undefined,
|
||||
});
|
||||
};
|
||||
|
||||
@ -49,44 +49,47 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
|
||||
downloadLink: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${token}/complete`,
|
||||
});
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: email,
|
||||
name,
|
||||
},
|
||||
from: {
|
||||
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
|
||||
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
|
||||
},
|
||||
subject: 'Signing Complete!',
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
attachments: [
|
||||
{
|
||||
filename: document.title,
|
||||
content: Buffer.from(buffer),
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: email,
|
||||
name,
|
||||
},
|
||||
],
|
||||
});
|
||||
from: {
|
||||
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
|
||||
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
|
||||
},
|
||||
subject: 'Signing Complete!',
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
attachments: [
|
||||
{
|
||||
filename: document.title,
|
||||
content: Buffer.from(buffer),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
||||
documentId: document.id,
|
||||
user: null,
|
||||
requestMetadata,
|
||||
data: {
|
||||
emailType: 'DOCUMENT_COMPLETED',
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
recipientId: recipient.id,
|
||||
recipientRole: recipient.role,
|
||||
isResending: false,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
||||
documentId: document.id,
|
||||
user: null,
|
||||
requestMetadata,
|
||||
data: {
|
||||
emailType: 'DOCUMENT_COMPLETED',
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
recipientId: recipient.id,
|
||||
recipientRole: recipient.role,
|
||||
isResending: false,
|
||||
},
|
||||
}),
|
||||
});
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
@ -10,22 +10,26 @@ import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-
|
||||
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, RecipientRole, SendStatus } from '@documenso/prisma/client';
|
||||
import { WebhookTriggerEvents } from '@documenso/prisma/client';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import {
|
||||
RECIPIENT_ROLES_DESCRIPTION,
|
||||
RECIPIENT_ROLE_TO_EMAIL_TYPE,
|
||||
} from '../../constants/recipient-roles';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
|
||||
export type SendDocumentOptions = {
|
||||
documentId: number;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
|
||||
export const sendDocument = async ({
|
||||
documentId,
|
||||
userId,
|
||||
teamId,
|
||||
requestMetadata,
|
||||
}: SendDocumentOptions) => {
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
@ -42,20 +46,21 @@ export const sendDocument = async ({
|
||||
const document = await prisma.document.findUnique({
|
||||
where: {
|
||||
id: documentId,
|
||||
OR: [
|
||||
{
|
||||
userId,
|
||||
},
|
||||
{
|
||||
team: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
...(teamId
|
||||
? {
|
||||
team: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
: {
|
||||
userId,
|
||||
teamId: null,
|
||||
}),
|
||||
},
|
||||
include: {
|
||||
Recipient: true,
|
||||
@ -108,59 +113,86 @@ export const sendDocument = async ({
|
||||
|
||||
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: email,
|
||||
name,
|
||||
},
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: customEmail?.subject
|
||||
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
|
||||
: `Please ${actionVerb.toLowerCase()} this document`,
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
});
|
||||
|
||||
await tx.recipient.update({
|
||||
where: {
|
||||
id: recipient.id,
|
||||
},
|
||||
data: {
|
||||
sendStatus: SendStatus.SENT,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
||||
documentId: document.id,
|
||||
user,
|
||||
requestMetadata,
|
||||
data: {
|
||||
emailType: recipientEmailType,
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
recipientRole: recipient.role,
|
||||
recipientId: recipient.id,
|
||||
isResending: false,
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: email,
|
||||
name,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: customEmail?.subject
|
||||
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
|
||||
: `Please ${actionVerb.toLowerCase()} this document`,
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
});
|
||||
|
||||
await tx.recipient.update({
|
||||
where: {
|
||||
id: recipient.id,
|
||||
},
|
||||
data: {
|
||||
sendStatus: SendStatus.SENT,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
||||
documentId: document.id,
|
||||
user,
|
||||
requestMetadata,
|
||||
data: {
|
||||
emailType: recipientEmailType,
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
recipientRole: recipient.role,
|
||||
recipientId: recipient.id,
|
||||
isResending: false,
|
||||
},
|
||||
}),
|
||||
});
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const updatedDocument = await prisma.document.update({
|
||||
where: {
|
||||
id: documentId,
|
||||
},
|
||||
data: {
|
||||
status: DocumentStatus.PENDING,
|
||||
},
|
||||
const updatedDocument = await prisma.$transaction(async (tx) => {
|
||||
if (document.status === DocumentStatus.DRAFT) {
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT,
|
||||
documentId: document.id,
|
||||
requestMetadata,
|
||||
user,
|
||||
data: {},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return await tx.document.update({
|
||||
where: {
|
||||
id: documentId,
|
||||
},
|
||||
data: {
|
||||
status: DocumentStatus.PENDING,
|
||||
},
|
||||
include: {
|
||||
Recipient: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_SENT,
|
||||
data: updatedDocument,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
return updatedDocument;
|
||||
|
||||
@ -5,16 +5,36 @@ import type { Prisma } from '@prisma/client';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type UpdateDocumentOptions = {
|
||||
documentId: number;
|
||||
data: Prisma.DocumentUpdateInput;
|
||||
userId: number;
|
||||
documentId: number;
|
||||
teamId?: number;
|
||||
};
|
||||
|
||||
export const updateDocument = async ({ documentId, userId, data }: UpdateDocumentOptions) => {
|
||||
export const updateDocument = async ({
|
||||
documentId,
|
||||
userId,
|
||||
teamId,
|
||||
data,
|
||||
}: UpdateDocumentOptions) => {
|
||||
return await prisma.document.update({
|
||||
where: {
|
||||
id: documentId,
|
||||
userId,
|
||||
...(teamId
|
||||
? {
|
||||
team: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
userId,
|
||||
teamId: null,
|
||||
}),
|
||||
},
|
||||
data: {
|
||||
...data,
|
||||
|
||||
@ -7,6 +7,7 @@ import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type UpdateTitleOptions = {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
documentId: number;
|
||||
title: string;
|
||||
requestMetadata?: RequestMetadata;
|
||||
@ -14,6 +15,7 @@ export type UpdateTitleOptions = {
|
||||
|
||||
export const updateTitle = async ({
|
||||
userId,
|
||||
teamId,
|
||||
documentId,
|
||||
title,
|
||||
requestMetadata,
|
||||
@ -24,34 +26,39 @@ export const updateTitle = async ({
|
||||
},
|
||||
});
|
||||
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const document = await tx.document.findFirstOrThrow({
|
||||
where: {
|
||||
id: documentId,
|
||||
OR: [
|
||||
{
|
||||
userId,
|
||||
},
|
||||
{
|
||||
const document = await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
id: documentId,
|
||||
...(teamId
|
||||
? {
|
||||
team: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
: {
|
||||
userId,
|
||||
teamId: null,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
if (document.title === title) {
|
||||
return document;
|
||||
}
|
||||
if (document.title === title) {
|
||||
return document;
|
||||
}
|
||||
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
// Instead of doing everything in a transaction we can use our knowledge
|
||||
// of the current document title to ensure we aren't performing a conflicting
|
||||
// update.
|
||||
const updatedDocument = await tx.document.update({
|
||||
where: {
|
||||
id: documentId,
|
||||
title: document.title,
|
||||
},
|
||||
data: {
|
||||
title,
|
||||
|
||||
@ -3,6 +3,10 @@ import type { RequestMetadata } from '@documenso/lib/universal/extract-request-m
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { ReadStatus } from '@documenso/prisma/client';
|
||||
import { WebhookTriggerEvents } from '@documenso/prisma/client';
|
||||
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
import { getDocumentAndRecipientByToken } from './get-document-by-token';
|
||||
|
||||
export type ViewedDocumentOptions = {
|
||||
token: string;
|
||||
@ -51,4 +55,13 @@ export const viewedDocument = async ({ token, requestMetadata }: ViewedDocumentO
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
const document = await getDocumentAndRecipientByToken({ token });
|
||||
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_OPENED,
|
||||
data: document,
|
||||
userId: document.userId,
|
||||
teamId: document.teamId ?? undefined,
|
||||
});
|
||||
};
|
||||
|
||||
126
packages/lib/server-only/field/create-field.ts
Normal file
126
packages/lib/server-only/field/create-field.ts
Normal file
@ -0,0 +1,126 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { FieldType, Team } from '@documenso/prisma/client';
|
||||
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
|
||||
export type CreateFieldOptions = {
|
||||
documentId: number;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
recipientId: number;
|
||||
type: FieldType;
|
||||
pageNumber: number;
|
||||
pageX: number;
|
||||
pageY: number;
|
||||
pageWidth: number;
|
||||
pageHeight: number;
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
|
||||
export const createField = async ({
|
||||
documentId,
|
||||
userId,
|
||||
teamId,
|
||||
recipientId,
|
||||
type,
|
||||
pageNumber,
|
||||
pageX,
|
||||
pageY,
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
requestMetadata,
|
||||
}: CreateFieldOptions) => {
|
||||
const document = await prisma.document.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
where: {
|
||||
id: documentId,
|
||||
...(teamId
|
||||
? {
|
||||
team: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
userId,
|
||||
teamId: null,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
throw new Error('Document not found');
|
||||
}
|
||||
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
});
|
||||
|
||||
let team: Team | null = null;
|
||||
|
||||
if (teamId) {
|
||||
team = await prisma.team.findFirst({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const field = await prisma.field.create({
|
||||
data: {
|
||||
documentId,
|
||||
recipientId,
|
||||
type,
|
||||
page: pageNumber,
|
||||
positionX: pageX,
|
||||
positionY: pageY,
|
||||
width: pageWidth,
|
||||
height: pageHeight,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
},
|
||||
include: {
|
||||
Recipient: true,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: 'FIELD_CREATED',
|
||||
documentId,
|
||||
user: {
|
||||
id: team?.id ?? user.id,
|
||||
email: team?.name ?? user.email,
|
||||
name: team ? '' : user.name,
|
||||
},
|
||||
data: {
|
||||
fieldId: field.secondaryId,
|
||||
fieldRecipientEmail: field.Recipient?.email ?? '',
|
||||
fieldRecipientId: recipientId,
|
||||
fieldType: field.type,
|
||||
},
|
||||
requestMetadata,
|
||||
}),
|
||||
});
|
||||
|
||||
return field;
|
||||
};
|
||||
90
packages/lib/server-only/field/delete-field.ts
Normal file
90
packages/lib/server-only/field/delete-field.ts
Normal file
@ -0,0 +1,90 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { Team } from '@documenso/prisma/client';
|
||||
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
|
||||
export type DeleteFieldOptions = {
|
||||
fieldId: number;
|
||||
documentId: number;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
|
||||
export const deleteField = async ({
|
||||
fieldId,
|
||||
userId,
|
||||
teamId,
|
||||
documentId,
|
||||
requestMetadata,
|
||||
}: DeleteFieldOptions) => {
|
||||
const field = await prisma.field.delete({
|
||||
where: {
|
||||
id: fieldId,
|
||||
Document: {
|
||||
id: documentId,
|
||||
...(teamId
|
||||
? {
|
||||
team: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
userId,
|
||||
teamId: null,
|
||||
}),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
Recipient: true,
|
||||
},
|
||||
});
|
||||
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
});
|
||||
|
||||
let team: Team | null = null;
|
||||
|
||||
if (teamId) {
|
||||
team = await prisma.team.findFirstOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: 'FIELD_DELETED',
|
||||
documentId,
|
||||
user: {
|
||||
id: team?.id ?? user.id,
|
||||
email: team?.name ?? user.email,
|
||||
name: team ? '' : user.name,
|
||||
},
|
||||
data: {
|
||||
fieldId: field.secondaryId,
|
||||
fieldRecipientEmail: field.Recipient?.email ?? '',
|
||||
fieldRecipientId: field.recipientId ?? -1,
|
||||
fieldType: field.type,
|
||||
},
|
||||
requestMetadata,
|
||||
}),
|
||||
});
|
||||
|
||||
return field;
|
||||
};
|
||||
17
packages/lib/server-only/field/get-field-by-id.ts
Normal file
17
packages/lib/server-only/field/get-field-by-id.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type GetFieldByIdOptions = {
|
||||
fieldId: number;
|
||||
documentId: number;
|
||||
};
|
||||
|
||||
export const getFieldById = async ({ fieldId, documentId }: GetFieldByIdOptions) => {
|
||||
const field = await prisma.field.findFirst({
|
||||
where: {
|
||||
id: fieldId,
|
||||
documentId,
|
||||
},
|
||||
});
|
||||
|
||||
return field;
|
||||
};
|
||||
122
packages/lib/server-only/field/update-field.ts
Normal file
122
packages/lib/server-only/field/update-field.ts
Normal file
@ -0,0 +1,122 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { FieldType, Team } from '@documenso/prisma/client';
|
||||
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
|
||||
export type UpdateFieldOptions = {
|
||||
fieldId: number;
|
||||
documentId: number;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
recipientId?: number;
|
||||
type?: FieldType;
|
||||
pageNumber?: number;
|
||||
pageX?: number;
|
||||
pageY?: number;
|
||||
pageWidth?: number;
|
||||
pageHeight?: number;
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
|
||||
export const updateField = async ({
|
||||
fieldId,
|
||||
documentId,
|
||||
userId,
|
||||
teamId,
|
||||
recipientId,
|
||||
type,
|
||||
pageNumber,
|
||||
pageX,
|
||||
pageY,
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
requestMetadata,
|
||||
}: UpdateFieldOptions) => {
|
||||
const field = await prisma.field.update({
|
||||
where: {
|
||||
id: fieldId,
|
||||
Document: {
|
||||
id: documentId,
|
||||
...(teamId
|
||||
? {
|
||||
team: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
userId,
|
||||
teamId: null,
|
||||
}),
|
||||
},
|
||||
},
|
||||
data: {
|
||||
recipientId,
|
||||
type,
|
||||
page: pageNumber,
|
||||
positionX: pageX,
|
||||
positionY: pageY,
|
||||
width: pageWidth,
|
||||
height: pageHeight,
|
||||
},
|
||||
include: {
|
||||
Recipient: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!field) {
|
||||
throw new Error('Field not found');
|
||||
}
|
||||
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
});
|
||||
|
||||
let team: Team | null = null;
|
||||
|
||||
if (teamId) {
|
||||
team = await prisma.team.findFirst({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: 'FIELD_UPDATED',
|
||||
documentId,
|
||||
user: {
|
||||
id: team?.id ?? user.id,
|
||||
email: team?.name ?? user.email,
|
||||
name: team ? '' : user.name,
|
||||
},
|
||||
data: {
|
||||
fieldId: field.secondaryId,
|
||||
fieldRecipientEmail: field.Recipient?.email ?? '',
|
||||
fieldRecipientId: recipientId ?? -1,
|
||||
fieldType: field.type,
|
||||
},
|
||||
requestMetadata,
|
||||
}),
|
||||
});
|
||||
|
||||
return field;
|
||||
};
|
||||
@ -1,3 +1,4 @@
|
||||
// https://github.com/Hopding/pdf-lib/issues/20#issuecomment-412852821
|
||||
import fontkit from '@pdf-lib/fontkit';
|
||||
import { PDFDocument, StandardFonts } from 'pdf-lib';
|
||||
|
||||
@ -73,13 +74,17 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
|
||||
height: imageHeight,
|
||||
});
|
||||
} else {
|
||||
let textWidth = font.widthOfTextAtSize(field.customText, fontSize);
|
||||
const longestLineInTextForWidth = field.customText
|
||||
.split('\n')
|
||||
.sort((a, b) => b.length - a.length)[0];
|
||||
|
||||
let textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
|
||||
const textHeight = font.heightAtSize(fontSize);
|
||||
|
||||
const scalingFactor = Math.min(fieldWidth / textWidth, fieldHeight / textHeight, 1);
|
||||
|
||||
fontSize = Math.max(Math.min(fontSize * scalingFactor, maxFontSize), minFontSize);
|
||||
textWidth = font.widthOfTextAtSize(field.customText, fontSize);
|
||||
textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
|
||||
|
||||
const textX = fieldX + (fieldWidth - textWidth) / 2;
|
||||
let textY = fieldY + (fieldHeight - textHeight) / 2;
|
||||
|
||||
67
packages/lib/server-only/public-api/create-api-token.ts
Normal file
67
packages/lib/server-only/public-api/create-api-token.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import type { Duration } from 'luxon';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { TeamMemberRole } from '@documenso/prisma/client';
|
||||
|
||||
// temporary choice for testing only
|
||||
import * as timeConstants from '../../constants/time';
|
||||
import { alphaid } from '../../universal/id';
|
||||
import { hashString } from '../auth/hash';
|
||||
|
||||
type TimeConstants = typeof timeConstants & {
|
||||
[key: string]: number | Duration;
|
||||
};
|
||||
|
||||
type CreateApiTokenInput = {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
tokenName: string;
|
||||
expiresIn: string | null;
|
||||
};
|
||||
|
||||
export const createApiToken = async ({
|
||||
userId,
|
||||
teamId,
|
||||
tokenName,
|
||||
expiresIn,
|
||||
}: CreateApiTokenInput) => {
|
||||
const apiToken = `api_${alphaid(16)}`;
|
||||
|
||||
const hashedToken = hashString(apiToken);
|
||||
|
||||
const timeConstantsRecords: TimeConstants = timeConstants;
|
||||
|
||||
if (teamId) {
|
||||
const member = await prisma.teamMember.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
teamId,
|
||||
role: TeamMemberRole.ADMIN,
|
||||
},
|
||||
});
|
||||
|
||||
if (!member) {
|
||||
throw new Error('You do not have permission to create a token for this team');
|
||||
}
|
||||
}
|
||||
|
||||
const storedToken = await prisma.apiToken.create({
|
||||
data: {
|
||||
name: tokenName,
|
||||
token: hashedToken,
|
||||
expires: expiresIn ? DateTime.now().plus(timeConstantsRecords[expiresIn]).toJSDate() : null,
|
||||
userId: teamId ? null : userId,
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!storedToken) {
|
||||
throw new Error('Failed to create the API token');
|
||||
}
|
||||
|
||||
return {
|
||||
id: storedToken.id,
|
||||
token: apiToken,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,32 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { TeamMemberRole } from '@documenso/prisma/client';
|
||||
|
||||
export type DeleteTokenByIdOptions = {
|
||||
id: number;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
};
|
||||
|
||||
export const deleteTokenById = async ({ id, userId, teamId }: DeleteTokenByIdOptions) => {
|
||||
if (teamId) {
|
||||
const member = await prisma.teamMember.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
teamId,
|
||||
role: TeamMemberRole.ADMIN,
|
||||
},
|
||||
});
|
||||
|
||||
if (!member) {
|
||||
throw new Error('You do not have permission to delete this token');
|
||||
}
|
||||
}
|
||||
|
||||
return await prisma.apiToken.delete({
|
||||
where: {
|
||||
id,
|
||||
userId: teamId ? null : userId,
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
};
|
||||
36
packages/lib/server-only/public-api/get-all-team-tokens.ts
Normal file
36
packages/lib/server-only/public-api/get-all-team-tokens.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { TeamMemberRole } from '@documenso/prisma/client';
|
||||
|
||||
export type GetUserTokensOptions = {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
export const getTeamTokens = async ({ userId, teamId }: GetUserTokensOptions) => {
|
||||
const teamMember = await prisma.teamMember.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
|
||||
if (teamMember?.role !== TeamMemberRole.ADMIN) {
|
||||
throw new Error('You do not have permission to view tokens for this team');
|
||||
}
|
||||
|
||||
return await prisma.apiToken.findMany({
|
||||
where: {
|
||||
teamId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
algorithm: true,
|
||||
createdAt: true,
|
||||
expires: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
};
|
||||
23
packages/lib/server-only/public-api/get-all-user-tokens.ts
Normal file
23
packages/lib/server-only/public-api/get-all-user-tokens.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type GetUserTokensOptions = {
|
||||
userId: number;
|
||||
};
|
||||
|
||||
export const getUserTokens = async ({ userId }: GetUserTokensOptions) => {
|
||||
return await prisma.apiToken.findMany({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
algorithm: true,
|
||||
createdAt: true,
|
||||
expires: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
};
|
||||
15
packages/lib/server-only/public-api/get-api-token-by-id.ts
Normal file
15
packages/lib/server-only/public-api/get-api-token-by-id.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type GetApiTokenByIdOptions = {
|
||||
id: number;
|
||||
userId: number;
|
||||
};
|
||||
|
||||
export const getApiTokenById = async ({ id, userId }: GetApiTokenByIdOptions) => {
|
||||
return await prisma.apiToken.findFirstOrThrow({
|
||||
where: {
|
||||
id,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,41 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { hashString } from '../auth/hash';
|
||||
|
||||
export const getApiTokenByToken = async ({ token }: { token: string }) => {
|
||||
const hashedToken = hashString(token);
|
||||
|
||||
const apiToken = await prisma.apiToken.findFirst({
|
||||
where: {
|
||||
token: hashedToken,
|
||||
},
|
||||
include: {
|
||||
team: true,
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!apiToken) {
|
||||
throw new Error('Invalid token');
|
||||
}
|
||||
|
||||
if (apiToken.expires && apiToken.expires < new Date()) {
|
||||
throw new Error('Expired token');
|
||||
}
|
||||
|
||||
if (apiToken.team) {
|
||||
apiToken.user = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: apiToken.team.ownerUserId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const { user } = apiToken;
|
||||
|
||||
if (!user) {
|
||||
throw new Error('Invalid token');
|
||||
}
|
||||
|
||||
return { ...apiToken, user };
|
||||
};
|
||||
37
packages/lib/server-only/public-api/get-user-by-token.ts
Normal file
37
packages/lib/server-only/public-api/get-user-by-token.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { hashString } from '../auth/hash';
|
||||
|
||||
export const getUserByApiToken = async ({ token }: { token: string }) => {
|
||||
const hashedToken = hashString(token);
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
ApiToken: {
|
||||
some: {
|
||||
token: hashedToken,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
ApiToken: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error('Invalid token');
|
||||
}
|
||||
|
||||
const retrievedToken = user.ApiToken.find((apiToken) => apiToken.token === hashedToken);
|
||||
|
||||
// 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');
|
||||
}
|
||||
|
||||
return user;
|
||||
};
|
||||
19
packages/lib/server-only/public-api/test-credentials.ts
Normal file
19
packages/lib/server-only/public-api/test-credentials.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
import { validateApiToken } from '@documenso/lib/server-only/webhooks/zapier/validateApiToken';
|
||||
|
||||
export const testCredentialsHandler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
try {
|
||||
const { authorization } = req.headers;
|
||||
|
||||
const result = await validateApiToken({ authorization });
|
||||
|
||||
return res.status(200).json({
|
||||
name: result.team?.name ?? result.user.name,
|
||||
});
|
||||
} catch (err) {
|
||||
return res.status(500).json({
|
||||
message: 'Internal Server Error',
|
||||
});
|
||||
}
|
||||
};
|
||||
106
packages/lib/server-only/recipient/delete-recipient.ts
Normal file
106
packages/lib/server-only/recipient/delete-recipient.ts
Normal file
@ -0,0 +1,106 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { Team } from '@documenso/prisma/client';
|
||||
import { SendStatus } from '@documenso/prisma/client';
|
||||
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
|
||||
export type DeleteRecipientOptions = {
|
||||
documentId: number;
|
||||
recipientId: number;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
|
||||
export const deleteRecipient = async ({
|
||||
documentId,
|
||||
recipientId,
|
||||
userId,
|
||||
teamId,
|
||||
requestMetadata,
|
||||
}: DeleteRecipientOptions) => {
|
||||
const recipient = await prisma.recipient.findFirst({
|
||||
where: {
|
||||
id: recipientId,
|
||||
Document: {
|
||||
id: documentId,
|
||||
...(teamId
|
||||
? {
|
||||
team: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
userId,
|
||||
teamId: null,
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!recipient) {
|
||||
throw new Error('Recipient not found');
|
||||
}
|
||||
|
||||
if (recipient.sendStatus !== SendStatus.NOT_SENT) {
|
||||
throw new Error('Can not delete a recipient that has already been sent a document');
|
||||
}
|
||||
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
let team: Team | null = null;
|
||||
|
||||
if (teamId) {
|
||||
team = await prisma.team.findFirst({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const deletedRecipient = await prisma.$transaction(async (tx) => {
|
||||
const deleted = await tx.recipient.delete({
|
||||
where: {
|
||||
id: recipient.id,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: 'RECIPIENT_DELETED',
|
||||
documentId,
|
||||
user: {
|
||||
id: team?.id ?? user.id,
|
||||
email: team?.name ?? user.email,
|
||||
name: team ? '' : user.name,
|
||||
},
|
||||
data: {
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
recipientId: recipient.id,
|
||||
recipientRole: recipient.role,
|
||||
},
|
||||
requestMetadata,
|
||||
}),
|
||||
});
|
||||
|
||||
return deleted;
|
||||
});
|
||||
|
||||
return deletedRecipient;
|
||||
};
|
||||
21
packages/lib/server-only/recipient/get-recipient-by-email.ts
Normal file
21
packages/lib/server-only/recipient/get-recipient-by-email.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type GetRecipientByEmailOptions = {
|
||||
documentId: number;
|
||||
email: string;
|
||||
};
|
||||
|
||||
export const getRecipientByEmail = async ({ documentId, email }: GetRecipientByEmailOptions) => {
|
||||
const recipient = await prisma.recipient.findFirst({
|
||||
where: {
|
||||
documentId,
|
||||
email: email.toLowerCase(),
|
||||
},
|
||||
});
|
||||
|
||||
if (!recipient) {
|
||||
throw new Error('Recipient not found');
|
||||
}
|
||||
|
||||
return recipient;
|
||||
};
|
||||
21
packages/lib/server-only/recipient/get-recipient-by-id.ts
Normal file
21
packages/lib/server-only/recipient/get-recipient-by-id.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type GetRecipientByIdOptions = {
|
||||
id: number;
|
||||
documentId: number;
|
||||
};
|
||||
|
||||
export const getRecipientById = async ({ documentId, id }: GetRecipientByIdOptions) => {
|
||||
const recipient = await prisma.recipient.findFirst({
|
||||
where: {
|
||||
documentId,
|
||||
id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!recipient) {
|
||||
throw new Error('Recipient not found');
|
||||
}
|
||||
|
||||
return recipient;
|
||||
};
|
||||
@ -3,11 +3,13 @@ import { prisma } from '@documenso/prisma';
|
||||
export interface GetRecipientsForDocumentOptions {
|
||||
documentId: number;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
}
|
||||
|
||||
export const getRecipientsForDocument = async ({
|
||||
documentId,
|
||||
userId,
|
||||
teamId,
|
||||
}: GetRecipientsForDocumentOptions) => {
|
||||
const recipients = await prisma.recipient.findMany({
|
||||
where: {
|
||||
@ -18,6 +20,7 @@ export const getRecipientsForDocument = async ({
|
||||
userId,
|
||||
},
|
||||
{
|
||||
teamId,
|
||||
team: {
|
||||
members: {
|
||||
some: {
|
||||
|
||||
@ -11,6 +11,7 @@ import { SendStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
|
||||
export interface SetRecipientsForDocumentOptions {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
documentId: number;
|
||||
recipients: {
|
||||
id?: number | null;
|
||||
@ -23,6 +24,7 @@ export interface SetRecipientsForDocumentOptions {
|
||||
|
||||
export const setRecipientsForDocument = async ({
|
||||
userId,
|
||||
teamId,
|
||||
documentId,
|
||||
recipients,
|
||||
requestMetadata,
|
||||
@ -30,20 +32,21 @@ export const setRecipientsForDocument = async ({
|
||||
const document = await prisma.document.findFirst({
|
||||
where: {
|
||||
id: documentId,
|
||||
OR: [
|
||||
{
|
||||
userId,
|
||||
},
|
||||
{
|
||||
team: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
...(teamId
|
||||
? {
|
||||
team: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
: {
|
||||
userId,
|
||||
teamId: null,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@ -106,7 +109,7 @@ export const setRecipientsForDocument = async ({
|
||||
});
|
||||
|
||||
const persistedRecipients = await prisma.$transaction(async (tx) => {
|
||||
await Promise.all(
|
||||
return await Promise.all(
|
||||
linkedRecipients.map(async (recipient) => {
|
||||
const upsertedRecipient = await tx.recipient.upsert({
|
||||
where: {
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { RecipientRole } from '@documenso/prisma/client';
|
||||
|
||||
import { nanoid } from '../../universal/id';
|
||||
|
||||
@ -9,6 +10,7 @@ export type SetRecipientsForTemplateOptions = {
|
||||
id?: number;
|
||||
email: string;
|
||||
name: string;
|
||||
role: RecipientRole;
|
||||
}[];
|
||||
};
|
||||
|
||||
@ -84,11 +86,13 @@ export const setRecipientsForTemplate = async ({
|
||||
update: {
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
role: recipient.role,
|
||||
templateId,
|
||||
},
|
||||
create: {
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
role: recipient.role,
|
||||
token: nanoid(),
|
||||
templateId,
|
||||
},
|
||||
|
||||
118
packages/lib/server-only/recipient/update-recipient.ts
Normal file
118
packages/lib/server-only/recipient/update-recipient.ts
Normal file
@ -0,0 +1,118 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { RecipientRole, Team } from '@documenso/prisma/client';
|
||||
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData, diffRecipientChanges } from '../../utils/document-audit-logs';
|
||||
|
||||
export type UpdateRecipientOptions = {
|
||||
documentId: number;
|
||||
recipientId: number;
|
||||
email?: string;
|
||||
name?: string;
|
||||
role?: RecipientRole;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
|
||||
export const updateRecipient = async ({
|
||||
documentId,
|
||||
recipientId,
|
||||
email,
|
||||
name,
|
||||
role,
|
||||
userId,
|
||||
teamId,
|
||||
requestMetadata,
|
||||
}: UpdateRecipientOptions) => {
|
||||
const recipient = await prisma.recipient.findFirst({
|
||||
where: {
|
||||
id: recipientId,
|
||||
Document: {
|
||||
id: documentId,
|
||||
...(teamId
|
||||
? {
|
||||
team: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
userId,
|
||||
teamId: null,
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let team: Team | null = null;
|
||||
|
||||
if (teamId) {
|
||||
team = await prisma.team.findFirst({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!recipient) {
|
||||
throw new Error('Recipient not found');
|
||||
}
|
||||
|
||||
const updatedRecipient = await prisma.$transaction(async (tx) => {
|
||||
const persisted = await prisma.recipient.update({
|
||||
where: {
|
||||
id: recipient.id,
|
||||
},
|
||||
data: {
|
||||
email: email?.toLowerCase() ?? recipient.email,
|
||||
name: name ?? recipient.name,
|
||||
role: role ?? recipient.role,
|
||||
},
|
||||
});
|
||||
|
||||
const changes = diffRecipientChanges(recipient, persisted);
|
||||
|
||||
if (changes.length > 0) {
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
|
||||
documentId: documentId,
|
||||
user: {
|
||||
id: team?.id ?? user.id,
|
||||
name: team?.name ?? user.name,
|
||||
email: team ? '' : user.email,
|
||||
},
|
||||
requestMetadata,
|
||||
data: {
|
||||
changes,
|
||||
recipientId,
|
||||
recipientEmail: persisted.email,
|
||||
recipientName: persisted.name,
|
||||
recipientRole: persisted.role,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return persisted;
|
||||
}
|
||||
});
|
||||
|
||||
return updatedRecipient;
|
||||
};
|
||||
@ -0,0 +1,9 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { ZSiteSettingsSchema } from './schema';
|
||||
|
||||
export const getSiteSettings = async () => {
|
||||
const settings = await prisma.siteSettings.findMany();
|
||||
|
||||
return ZSiteSettingsSchema.parse(settings);
|
||||
};
|
||||
12
packages/lib/server-only/site-settings/schema.ts
Normal file
12
packages/lib/server-only/site-settings/schema.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZSiteSettingsBannerSchema } from './schemas/banner';
|
||||
|
||||
// TODO: Use `z.union([...])` once we have more than one setting
|
||||
export const ZSiteSettingSchema = ZSiteSettingsBannerSchema;
|
||||
|
||||
export type TSiteSettingSchema = z.infer<typeof ZSiteSettingSchema>;
|
||||
|
||||
export const ZSiteSettingsSchema = z.array(ZSiteSettingSchema);
|
||||
|
||||
export type TSiteSettingsSchema = z.infer<typeof ZSiteSettingsSchema>;
|
||||
9
packages/lib/server-only/site-settings/schemas/_base.ts
Normal file
9
packages/lib/server-only/site-settings/schemas/_base.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZSiteSettingsBaseSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
enabled: z.boolean(),
|
||||
data: z.never(),
|
||||
});
|
||||
|
||||
export type TSiteSettingsBaseSchema = z.infer<typeof ZSiteSettingsBaseSchema>;
|
||||
23
packages/lib/server-only/site-settings/schemas/banner.ts
Normal file
23
packages/lib/server-only/site-settings/schemas/banner.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZSiteSettingsBaseSchema } from './_base';
|
||||
|
||||
export const SITE_SETTINGS_BANNER_ID = 'site.banner';
|
||||
|
||||
export const ZSiteSettingsBannerSchema = ZSiteSettingsBaseSchema.extend({
|
||||
id: z.literal(SITE_SETTINGS_BANNER_ID),
|
||||
data: z
|
||||
.object({
|
||||
content: z.string(),
|
||||
bgColor: z.string(),
|
||||
textColor: z.string(),
|
||||
})
|
||||
.optional()
|
||||
.default({
|
||||
content: '',
|
||||
bgColor: '#000000',
|
||||
textColor: '#FFFFFF',
|
||||
}),
|
||||
});
|
||||
|
||||
export type TSiteSettingsBannerSchema = z.infer<typeof ZSiteSettingsBannerSchema>;
|
||||
@ -0,0 +1,33 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import type { TSiteSettingSchema } from './schema';
|
||||
|
||||
export type UpsertSiteSettingOptions = TSiteSettingSchema & {
|
||||
userId: number;
|
||||
};
|
||||
|
||||
export const upsertSiteSetting = async ({
|
||||
id,
|
||||
enabled,
|
||||
data,
|
||||
userId,
|
||||
}: UpsertSiteSettingOptions) => {
|
||||
return await prisma.siteSettings.upsert({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
create: {
|
||||
id,
|
||||
enabled,
|
||||
data,
|
||||
lastModifiedByUserId: userId,
|
||||
lastModifiedAt: new Date(),
|
||||
},
|
||||
update: {
|
||||
enabled,
|
||||
data,
|
||||
lastModifiedByUserId: userId,
|
||||
lastModifiedAt: new Date(),
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -9,55 +9,58 @@ export type AcceptTeamInvitationOptions = {
|
||||
};
|
||||
|
||||
export const acceptTeamInvitation = async ({ userId, teamId }: AcceptTeamInvitationOptions) => {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const user = await tx.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
const user = await tx.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
const teamMemberInvite = await tx.teamMemberInvite.findFirstOrThrow({
|
||||
where: {
|
||||
teamId,
|
||||
email: user.email,
|
||||
},
|
||||
include: {
|
||||
team: {
|
||||
include: {
|
||||
subscription: true,
|
||||
const teamMemberInvite = await tx.teamMemberInvite.findFirstOrThrow({
|
||||
where: {
|
||||
teamId,
|
||||
email: user.email,
|
||||
},
|
||||
include: {
|
||||
team: {
|
||||
include: {
|
||||
subscription: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const { team } = teamMemberInvite;
|
||||
const { team } = teamMemberInvite;
|
||||
|
||||
await tx.teamMember.create({
|
||||
data: {
|
||||
teamId: teamMemberInvite.teamId,
|
||||
userId: user.id,
|
||||
role: teamMemberInvite.role,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.teamMemberInvite.delete({
|
||||
where: {
|
||||
id: teamMemberInvite.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (IS_BILLING_ENABLED() && team.subscription) {
|
||||
const numberOfSeats = await tx.teamMember.count({
|
||||
where: {
|
||||
await tx.teamMember.create({
|
||||
data: {
|
||||
teamId: teamMemberInvite.teamId,
|
||||
userId: user.id,
|
||||
role: teamMemberInvite.role,
|
||||
},
|
||||
});
|
||||
|
||||
await updateSubscriptionItemQuantity({
|
||||
priceId: team.subscription.priceId,
|
||||
subscriptionId: team.subscription.planId,
|
||||
quantity: numberOfSeats,
|
||||
await tx.teamMemberInvite.delete({
|
||||
where: {
|
||||
id: teamMemberInvite.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (IS_BILLING_ENABLED() && team.subscription) {
|
||||
const numberOfSeats = await tx.teamMember.count({
|
||||
where: {
|
||||
teamId: teamMemberInvite.teamId,
|
||||
},
|
||||
});
|
||||
|
||||
await updateSubscriptionItemQuantity({
|
||||
priceId: team.subscription.priceId,
|
||||
subscriptionId: team.subscription.planId,
|
||||
quantity: numberOfSeats,
|
||||
});
|
||||
}
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
};
|
||||
|
||||
@ -28,56 +28,59 @@ export const createTeamEmailVerification = async ({
|
||||
data,
|
||||
}: CreateTeamEmailVerificationOptions) => {
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const team = await tx.team.findFirstOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
role: {
|
||||
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
const team = await tx.team.findFirstOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
role: {
|
||||
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
teamEmail: true,
|
||||
emailVerification: true,
|
||||
},
|
||||
});
|
||||
include: {
|
||||
teamEmail: true,
|
||||
emailVerification: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (team.teamEmail || team.emailVerification) {
|
||||
throw new AppError(
|
||||
AppErrorCode.INVALID_REQUEST,
|
||||
'Team already has an email or existing email verification.',
|
||||
);
|
||||
}
|
||||
if (team.teamEmail || team.emailVerification) {
|
||||
throw new AppError(
|
||||
AppErrorCode.INVALID_REQUEST,
|
||||
'Team already has an email or existing email verification.',
|
||||
);
|
||||
}
|
||||
|
||||
const existingTeamEmail = await tx.teamEmail.findFirst({
|
||||
where: {
|
||||
email: data.email,
|
||||
},
|
||||
});
|
||||
const existingTeamEmail = await tx.teamEmail.findFirst({
|
||||
where: {
|
||||
email: data.email,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingTeamEmail) {
|
||||
throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Email already taken by another team.');
|
||||
}
|
||||
if (existingTeamEmail) {
|
||||
throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Email already taken by another team.');
|
||||
}
|
||||
|
||||
const { token, expiresAt } = createTokenVerification({ hours: 1 });
|
||||
const { token, expiresAt } = createTokenVerification({ hours: 1 });
|
||||
|
||||
await tx.teamEmailVerification.create({
|
||||
data: {
|
||||
token,
|
||||
expiresAt,
|
||||
email: data.email,
|
||||
name: data.name,
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
await tx.teamEmailVerification.create({
|
||||
data: {
|
||||
token,
|
||||
expiresAt,
|
||||
email: data.email,
|
||||
name: data.name,
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
|
||||
await sendTeamEmailVerificationEmail(data.email, token, team.name, team.url);
|
||||
});
|
||||
await sendTeamEmailVerificationEmail(data.email, token, team.name, team.url);
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
|
||||
@ -27,76 +27,81 @@ export const deleteTeamMembers = async ({
|
||||
teamId,
|
||||
teamMemberIds,
|
||||
}: DeleteTeamMembersOptions) => {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Find the team and validate that the user is allowed to remove members.
|
||||
const team = await tx.team.findFirstOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
role: {
|
||||
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
// Find the team and validate that the user is allowed to remove members.
|
||||
const team = await tx.team.findFirstOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
role: {
|
||||
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
members: {
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
role: true,
|
||||
include: {
|
||||
members: {
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
subscription: true,
|
||||
},
|
||||
});
|
||||
|
||||
const currentTeamMember = team.members.find((member) => member.userId === userId);
|
||||
const teamMembersToRemove = team.members.filter((member) =>
|
||||
teamMemberIds.includes(member.id),
|
||||
);
|
||||
|
||||
if (!currentTeamMember) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, 'Team member record does not exist');
|
||||
}
|
||||
|
||||
if (teamMembersToRemove.find((member) => member.userId === team.ownerUserId)) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Cannot remove the team owner');
|
||||
}
|
||||
|
||||
const isMemberToRemoveHigherRole = teamMembersToRemove.some(
|
||||
(member) => !isTeamRoleWithinUserHierarchy(currentTeamMember.role, member.role),
|
||||
);
|
||||
|
||||
if (isMemberToRemoveHigherRole) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Cannot remove a member with a higher role');
|
||||
}
|
||||
|
||||
// Remove the team members.
|
||||
await tx.teamMember.deleteMany({
|
||||
where: {
|
||||
id: {
|
||||
in: teamMemberIds,
|
||||
},
|
||||
teamId,
|
||||
userId: {
|
||||
not: team.ownerUserId,
|
||||
},
|
||||
},
|
||||
subscription: true,
|
||||
},
|
||||
});
|
||||
|
||||
const currentTeamMember = team.members.find((member) => member.userId === userId);
|
||||
const teamMembersToRemove = team.members.filter((member) => teamMemberIds.includes(member.id));
|
||||
|
||||
if (!currentTeamMember) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, 'Team member record does not exist');
|
||||
}
|
||||
|
||||
if (teamMembersToRemove.find((member) => member.userId === team.ownerUserId)) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Cannot remove the team owner');
|
||||
}
|
||||
|
||||
const isMemberToRemoveHigherRole = teamMembersToRemove.some(
|
||||
(member) => !isTeamRoleWithinUserHierarchy(currentTeamMember.role, member.role),
|
||||
);
|
||||
|
||||
if (isMemberToRemoveHigherRole) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Cannot remove a member with a higher role');
|
||||
}
|
||||
|
||||
// Remove the team members.
|
||||
await tx.teamMember.deleteMany({
|
||||
where: {
|
||||
id: {
|
||||
in: teamMemberIds,
|
||||
},
|
||||
teamId,
|
||||
userId: {
|
||||
not: team.ownerUserId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (IS_BILLING_ENABLED() && team.subscription) {
|
||||
const numberOfSeats = await tx.teamMember.count({
|
||||
where: {
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
|
||||
await updateSubscriptionItemQuantity({
|
||||
priceId: team.subscription.priceId,
|
||||
subscriptionId: team.subscription.planId,
|
||||
quantity: numberOfSeats,
|
||||
});
|
||||
}
|
||||
});
|
||||
if (IS_BILLING_ENABLED() && team.subscription) {
|
||||
const numberOfSeats = await tx.teamMember.count({
|
||||
where: {
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
|
||||
await updateSubscriptionItemQuantity({
|
||||
priceId: team.subscription.priceId,
|
||||
subscriptionId: team.subscription.planId,
|
||||
quantity: numberOfSeats,
|
||||
});
|
||||
}
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
};
|
||||
|
||||
@ -9,34 +9,37 @@ export type DeleteTeamOptions = {
|
||||
};
|
||||
|
||||
export const deleteTeam = async ({ userId, teamId }: DeleteTeamOptions) => {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const team = await tx.team.findFirstOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
ownerUserId: userId,
|
||||
},
|
||||
include: {
|
||||
subscription: true,
|
||||
},
|
||||
});
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
const team = await tx.team.findFirstOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
ownerUserId: userId,
|
||||
},
|
||||
include: {
|
||||
subscription: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (team.subscription) {
|
||||
await stripe.subscriptions
|
||||
.cancel(team.subscription.planId, {
|
||||
prorate: false,
|
||||
invoice_now: true,
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
throw AppError.parseError(err);
|
||||
});
|
||||
}
|
||||
if (team.subscription) {
|
||||
await stripe.subscriptions
|
||||
.cancel(team.subscription.planId, {
|
||||
prorate: false,
|
||||
invoice_now: true,
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
throw AppError.parseError(err);
|
||||
});
|
||||
}
|
||||
|
||||
await tx.team.delete({
|
||||
where: {
|
||||
id: teamId,
|
||||
ownerUserId: userId,
|
||||
},
|
||||
});
|
||||
});
|
||||
await tx.team.delete({
|
||||
where: {
|
||||
id: teamId,
|
||||
ownerUserId: userId,
|
||||
},
|
||||
});
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
};
|
||||
|
||||
@ -15,45 +15,48 @@ export type LeaveTeamOptions = {
|
||||
};
|
||||
|
||||
export const leaveTeam = async ({ userId, teamId }: LeaveTeamOptions) => {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const team = await tx.team.findFirstOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
ownerUserId: {
|
||||
not: userId,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
subscription: true,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.teamMember.delete({
|
||||
where: {
|
||||
userId_teamId: {
|
||||
userId,
|
||||
teamId,
|
||||
},
|
||||
team: {
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
const team = await tx.team.findFirstOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
ownerUserId: {
|
||||
not: userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (IS_BILLING_ENABLED() && team.subscription) {
|
||||
const numberOfSeats = await tx.teamMember.count({
|
||||
where: {
|
||||
teamId,
|
||||
include: {
|
||||
subscription: true,
|
||||
},
|
||||
});
|
||||
|
||||
await updateSubscriptionItemQuantity({
|
||||
priceId: team.subscription.priceId,
|
||||
subscriptionId: team.subscription.planId,
|
||||
quantity: numberOfSeats,
|
||||
await tx.teamMember.delete({
|
||||
where: {
|
||||
userId_teamId: {
|
||||
userId,
|
||||
teamId,
|
||||
},
|
||||
team: {
|
||||
ownerUserId: {
|
||||
not: userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (IS_BILLING_ENABLED() && team.subscription) {
|
||||
const numberOfSeats = await tx.teamMember.count({
|
||||
where: {
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
|
||||
await updateSubscriptionItemQuantity({
|
||||
priceId: team.subscription.priceId,
|
||||
subscriptionId: team.subscription.planId,
|
||||
quantity: numberOfSeats,
|
||||
});
|
||||
}
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
};
|
||||
|
||||
@ -44,63 +44,66 @@ export const requestTeamOwnershipTransfer = async ({
|
||||
// Todo: Clear payment methods disabled for now.
|
||||
const clearPaymentMethods = false;
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const team = await tx.team.findFirstOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
ownerUserId: userId,
|
||||
members: {
|
||||
some: {
|
||||
userId: newOwnerUserId,
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
const team = await tx.team.findFirstOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
ownerUserId: userId,
|
||||
members: {
|
||||
some: {
|
||||
userId: newOwnerUserId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const newOwnerUser = await tx.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: newOwnerUserId,
|
||||
},
|
||||
});
|
||||
const newOwnerUser = await tx.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: newOwnerUserId,
|
||||
},
|
||||
});
|
||||
|
||||
const { token, expiresAt } = createTokenVerification({ minute: 10 });
|
||||
const { token, expiresAt } = createTokenVerification({ minute: 10 });
|
||||
|
||||
const teamVerificationPayload = {
|
||||
teamId,
|
||||
token,
|
||||
expiresAt,
|
||||
userId: newOwnerUserId,
|
||||
name: newOwnerUser.name ?? '',
|
||||
email: newOwnerUser.email,
|
||||
clearPaymentMethods,
|
||||
};
|
||||
|
||||
await tx.teamTransferVerification.upsert({
|
||||
where: {
|
||||
const teamVerificationPayload = {
|
||||
teamId,
|
||||
},
|
||||
create: teamVerificationPayload,
|
||||
update: teamVerificationPayload,
|
||||
});
|
||||
token,
|
||||
expiresAt,
|
||||
userId: newOwnerUserId,
|
||||
name: newOwnerUser.name ?? '',
|
||||
email: newOwnerUser.email,
|
||||
clearPaymentMethods,
|
||||
};
|
||||
|
||||
const template = createElement(TeamTransferRequestTemplate, {
|
||||
assetBaseUrl: WEBAPP_BASE_URL,
|
||||
baseUrl: WEBAPP_BASE_URL,
|
||||
senderName: userName,
|
||||
teamName: team.name,
|
||||
teamUrl: team.url,
|
||||
token,
|
||||
});
|
||||
await tx.teamTransferVerification.upsert({
|
||||
where: {
|
||||
teamId,
|
||||
},
|
||||
create: teamVerificationPayload,
|
||||
update: teamVerificationPayload,
|
||||
});
|
||||
|
||||
await mailer.sendMail({
|
||||
to: newOwnerUser.email,
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: `You have been requested to take ownership of team ${team.name} on Documenso`,
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
});
|
||||
});
|
||||
const template = createElement(TeamTransferRequestTemplate, {
|
||||
assetBaseUrl: WEBAPP_BASE_URL,
|
||||
baseUrl: WEBAPP_BASE_URL,
|
||||
senderName: userName,
|
||||
teamName: team.name,
|
||||
teamUrl: team.url,
|
||||
token,
|
||||
});
|
||||
|
||||
await mailer.sendMail({
|
||||
to: newOwnerUser.email,
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: `You have been requested to take ownership of team ${team.name} on Documenso`,
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
});
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
};
|
||||
|
||||
@ -17,49 +17,52 @@ export const resendTeamEmailVerification = async ({
|
||||
userId,
|
||||
teamId,
|
||||
}: ResendTeamMemberInvitationOptions) => {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const team = await tx.team.findUniqueOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
role: {
|
||||
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
const team = await tx.team.findUniqueOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
role: {
|
||||
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
emailVerification: true,
|
||||
},
|
||||
});
|
||||
include: {
|
||||
emailVerification: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
throw new AppError('TeamNotFound', 'User is not a member of the team.');
|
||||
}
|
||||
if (!team) {
|
||||
throw new AppError('TeamNotFound', 'User is not a member of the team.');
|
||||
}
|
||||
|
||||
const { emailVerification } = team;
|
||||
const { emailVerification } = team;
|
||||
|
||||
if (!emailVerification) {
|
||||
throw new AppError(
|
||||
'VerificationNotFound',
|
||||
'No team email verification exists for this team.',
|
||||
);
|
||||
}
|
||||
if (!emailVerification) {
|
||||
throw new AppError(
|
||||
'VerificationNotFound',
|
||||
'No team email verification exists for this team.',
|
||||
);
|
||||
}
|
||||
|
||||
const { token, expiresAt } = createTokenVerification({ hours: 1 });
|
||||
const { token, expiresAt } = createTokenVerification({ hours: 1 });
|
||||
|
||||
await tx.teamEmailVerification.update({
|
||||
where: {
|
||||
teamId,
|
||||
},
|
||||
data: {
|
||||
token,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
await tx.teamEmailVerification.update({
|
||||
where: {
|
||||
teamId,
|
||||
},
|
||||
data: {
|
||||
token,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
await sendTeamEmailVerificationEmail(emailVerification.email, token, team.name, team.url);
|
||||
});
|
||||
await sendTeamEmailVerificationEmail(emailVerification.email, token, team.name, team.url);
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
};
|
||||
|
||||
@ -35,42 +35,45 @@ export const resendTeamMemberInvitation = async ({
|
||||
teamId,
|
||||
invitationId,
|
||||
}: ResendTeamMemberInvitationOptions) => {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const team = await tx.team.findUniqueOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
role: {
|
||||
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
const team = await tx.team.findUniqueOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
role: {
|
||||
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
throw new AppError('TeamNotFound', 'User is not a valid member of the team.');
|
||||
}
|
||||
if (!team) {
|
||||
throw new AppError('TeamNotFound', 'User is not a valid member of the team.');
|
||||
}
|
||||
|
||||
const teamMemberInvite = await tx.teamMemberInvite.findUniqueOrThrow({
|
||||
where: {
|
||||
id: invitationId,
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
const teamMemberInvite = await tx.teamMemberInvite.findUniqueOrThrow({
|
||||
where: {
|
||||
id: invitationId,
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!teamMemberInvite) {
|
||||
throw new AppError('InviteNotFound', 'No invite exists for this user.');
|
||||
}
|
||||
if (!teamMemberInvite) {
|
||||
throw new AppError('InviteNotFound', 'No invite exists for this user.');
|
||||
}
|
||||
|
||||
await sendTeamMemberInviteEmail({
|
||||
email: teamMemberInvite.email,
|
||||
token: teamMemberInvite.token,
|
||||
teamName: team.name,
|
||||
teamUrl: team.url,
|
||||
senderName: userName,
|
||||
});
|
||||
});
|
||||
await sendTeamMemberInviteEmail({
|
||||
email: teamMemberInvite.email,
|
||||
token: teamMemberInvite.token,
|
||||
teamName: team.name,
|
||||
teamUrl: team.url,
|
||||
senderName: userName,
|
||||
});
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
};
|
||||
|
||||
@ -11,78 +11,81 @@ export type TransferTeamOwnershipOptions = {
|
||||
};
|
||||
|
||||
export const transferTeamOwnership = async ({ token }: TransferTeamOwnershipOptions) => {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const teamTransferVerification = await tx.teamTransferVerification.findFirstOrThrow({
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
include: {
|
||||
team: {
|
||||
include: {
|
||||
subscription: true,
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
const teamTransferVerification = await tx.teamTransferVerification.findFirstOrThrow({
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
include: {
|
||||
team: {
|
||||
include: {
|
||||
subscription: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { team, userId: newOwnerUserId } = teamTransferVerification;
|
||||
|
||||
await tx.teamTransferVerification.delete({
|
||||
where: {
|
||||
teamId: team.id,
|
||||
},
|
||||
});
|
||||
|
||||
const newOwnerUser = await tx.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: newOwnerUserId,
|
||||
teamMembers: {
|
||||
some: {
|
||||
teamId: team.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
Subscription: true,
|
||||
},
|
||||
});
|
||||
|
||||
let teamSubscription: Stripe.Subscription | null = null;
|
||||
|
||||
if (IS_BILLING_ENABLED()) {
|
||||
teamSubscription = await transferTeamSubscription({
|
||||
user: newOwnerUser,
|
||||
team,
|
||||
clearPaymentMethods: teamTransferVerification.clearPaymentMethods,
|
||||
});
|
||||
}
|
||||
|
||||
if (teamSubscription) {
|
||||
await tx.subscription.upsert(
|
||||
mapStripeSubscriptionToPrismaUpsertAction(teamSubscription, undefined, team.id),
|
||||
);
|
||||
}
|
||||
const { team, userId: newOwnerUserId } = teamTransferVerification;
|
||||
|
||||
await tx.team.update({
|
||||
where: {
|
||||
id: team.id,
|
||||
},
|
||||
data: {
|
||||
ownerUserId: newOwnerUserId,
|
||||
members: {
|
||||
update: {
|
||||
where: {
|
||||
userId_teamId: {
|
||||
teamId: team.id,
|
||||
userId: newOwnerUserId,
|
||||
await tx.teamTransferVerification.delete({
|
||||
where: {
|
||||
teamId: team.id,
|
||||
},
|
||||
});
|
||||
|
||||
const newOwnerUser = await tx.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: newOwnerUserId,
|
||||
teamMembers: {
|
||||
some: {
|
||||
teamId: team.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
Subscription: true,
|
||||
},
|
||||
});
|
||||
|
||||
let teamSubscription: Stripe.Subscription | null = null;
|
||||
|
||||
if (IS_BILLING_ENABLED()) {
|
||||
teamSubscription = await transferTeamSubscription({
|
||||
user: newOwnerUser,
|
||||
team,
|
||||
clearPaymentMethods: teamTransferVerification.clearPaymentMethods,
|
||||
});
|
||||
}
|
||||
|
||||
if (teamSubscription) {
|
||||
await tx.subscription.upsert(
|
||||
mapStripeSubscriptionToPrismaUpsertAction(teamSubscription, undefined, team.id),
|
||||
);
|
||||
}
|
||||
|
||||
await tx.team.update({
|
||||
where: {
|
||||
id: team.id,
|
||||
},
|
||||
data: {
|
||||
ownerUserId: newOwnerUserId,
|
||||
members: {
|
||||
update: {
|
||||
where: {
|
||||
userId_teamId: {
|
||||
teamId: team.id,
|
||||
userId: newOwnerUserId,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
role: TeamMemberRole.ADMIN,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
role: TeamMemberRole.ADMIN,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,32 +1,42 @@
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
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;
|
||||
teamId?: number;
|
||||
recipients?: {
|
||||
name?: string;
|
||||
email: string;
|
||||
role?: RecipientRole;
|
||||
}[];
|
||||
};
|
||||
|
||||
export const createDocumentFromTemplate = async ({
|
||||
templateId,
|
||||
userId,
|
||||
teamId,
|
||||
recipients,
|
||||
}: CreateDocumentFromTemplateOptions) => {
|
||||
const template = await prisma.template.findUnique({
|
||||
where: {
|
||||
id: templateId,
|
||||
OR: [
|
||||
{
|
||||
userId,
|
||||
},
|
||||
{
|
||||
team: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
...(teamId
|
||||
? {
|
||||
team: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
: {
|
||||
userId,
|
||||
teamId: null,
|
||||
}),
|
||||
},
|
||||
include: {
|
||||
Recipient: true,
|
||||
@ -57,13 +67,18 @@ export const createDocumentFromTemplate = async ({
|
||||
create: template.Recipient.map((recipient) => ({
|
||||
email: recipient.email,
|
||||
name: recipient.name,
|
||||
role: recipient.role,
|
||||
token: nanoid(),
|
||||
})),
|
||||
},
|
||||
},
|
||||
|
||||
include: {
|
||||
Recipient: true,
|
||||
Recipient: {
|
||||
orderBy: {
|
||||
id: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -88,5 +103,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;
|
||||
};
|
||||
|
||||
@ -38,6 +38,7 @@ export const findTemplates = async ({
|
||||
include: {
|
||||
templateDocumentData: true,
|
||||
Field: true,
|
||||
Recipient: true,
|
||||
},
|
||||
skip: Math.max(page - 1, 0) * perPage,
|
||||
orderBy: {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user