Merge branch 'main' of https://github.com/Gautam-Hegde/documenso
1
packages/api/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export {};
|
||||
1
packages/api/next.ts
Normal file
@ -0,0 +1 @@
|
||||
export { createNextRouter } from '@ts-rest/next';
|
||||
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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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>;
|
||||
41
packages/app-tests/e2e/fixtures/authentication.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
||||
|
||||
type ManualLoginOptions = {
|
||||
page: Page;
|
||||
email?: string;
|
||||
password?: string;
|
||||
|
||||
/**
|
||||
* Where to navigate after login.
|
||||
*/
|
||||
redirectPath?: string;
|
||||
};
|
||||
|
||||
export const manualLogin = async ({
|
||||
page,
|
||||
email = 'example@documenso.com',
|
||||
password = 'password',
|
||||
redirectPath,
|
||||
}: ManualLoginOptions) => {
|
||||
await page.goto(`${WEBAPP_BASE_URL}/signin`);
|
||||
|
||||
await page.getByLabel('Email').click();
|
||||
await page.getByLabel('Email').fill(email);
|
||||
|
||||
await page.getByLabel('Password', { exact: true }).fill(password);
|
||||
await page.getByLabel('Password', { exact: true }).press('Enter');
|
||||
|
||||
if (redirectPath) {
|
||||
await page.waitForURL(`${WEBAPP_BASE_URL}/documents`);
|
||||
await page.goto(`${WEBAPP_BASE_URL}${redirectPath}`);
|
||||
}
|
||||
};
|
||||
|
||||
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`);
|
||||
};
|
||||
@ -2,6 +2,8 @@ import { expect, test } from '@playwright/test';
|
||||
|
||||
import { TEST_USERS } from '@documenso/prisma/seed/pr-711-deletion-of-documents';
|
||||
|
||||
import { manualLogin, manualSignout } from './fixtures/authentication';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test('[PR-711]: seeded documents should be visible', async ({ page }) => {
|
||||
@ -19,17 +21,11 @@ test('[PR-711]: seeded documents should be visible', async ({ page }) => {
|
||||
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Document 1 - Draft' })).toBeVisible();
|
||||
|
||||
await page.getByTitle('Profile Dropdown').click();
|
||||
await page.getByRole('menuitem', { name: 'Sign Out' }).click();
|
||||
|
||||
await page.waitForURL('/signin');
|
||||
await manualSignout({ page });
|
||||
|
||||
for (const recipient of recipients) {
|
||||
await page.goto('/signin');
|
||||
|
||||
await page.getByLabel('Email').fill(recipient.email);
|
||||
await page.getByLabel('Password', { exact: true }).fill(recipient.password);
|
||||
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||
await page.waitForURL('/signin');
|
||||
await manualLogin({ page, email: recipient.email, password: recipient.password });
|
||||
|
||||
await page.waitForURL('/documents');
|
||||
|
||||
@ -38,10 +34,7 @@ test('[PR-711]: seeded documents should be visible', async ({ page }) => {
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Document 1 - Draft' })).not.toBeVisible();
|
||||
|
||||
await page.getByTitle('Profile Dropdown').click();
|
||||
await page.getByRole('menuitem', { name: 'Sign Out' }).click();
|
||||
|
||||
await page.waitForURL('/signin');
|
||||
await manualSignout({ page });
|
||||
}
|
||||
});
|
||||
|
||||
@ -74,13 +67,10 @@ test('[PR-711]: deleting a completed document should not remove it from recipien
|
||||
|
||||
await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).not.toBeVisible();
|
||||
|
||||
// signout
|
||||
await page.getByTitle('Profile Dropdown').click();
|
||||
await page.getByRole('menuitem', { name: 'Sign Out' }).click();
|
||||
|
||||
await page.waitForURL('/signin');
|
||||
await manualSignout({ page });
|
||||
|
||||
for (const recipient of recipients) {
|
||||
await page.waitForURL('/signin');
|
||||
await page.goto('/signin');
|
||||
|
||||
// sign in
|
||||
@ -96,11 +86,7 @@ test('[PR-711]: deleting a completed document should not remove it from recipien
|
||||
await expect(page.getByText('Everyone has signed').nth(0)).toBeVisible();
|
||||
|
||||
await page.goto('/documents');
|
||||
|
||||
await page.getByTitle('Profile Dropdown').click();
|
||||
await page.getByRole('menuitem', { name: 'Sign Out' }).click();
|
||||
|
||||
await page.waitForURL('/signin');
|
||||
await manualSignout({ page });
|
||||
}
|
||||
});
|
||||
|
||||
@ -115,11 +101,7 @@ test('[PR-711]: deleting a pending document should remove it from recipients', a
|
||||
|
||||
await page.goto('/signin');
|
||||
|
||||
// sign in
|
||||
await page.getByLabel('Email').fill(sender.email);
|
||||
await page.getByLabel('Password', { exact: true }).fill(sender.password);
|
||||
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||
|
||||
await manualLogin({ page, email: sender.email, password: sender.password });
|
||||
await page.waitForURL('/documents');
|
||||
|
||||
// open actions menu
|
||||
@ -133,19 +115,12 @@ test('[PR-711]: deleting a pending document should remove it from recipients', a
|
||||
await expect(page.getByRole('row', { name: /Document 1 - Pending/ })).not.toBeVisible();
|
||||
|
||||
// signout
|
||||
await page.getByTitle('Profile Dropdown').click();
|
||||
await page.getByRole('menuitem', { name: 'Sign Out' }).click();
|
||||
|
||||
await page.waitForURL('/signin');
|
||||
await manualSignout({ page });
|
||||
|
||||
for (const recipient of recipients) {
|
||||
await page.goto('/signin');
|
||||
|
||||
// sign in
|
||||
await page.getByLabel('Email').fill(recipient.email);
|
||||
await page.getByLabel('Password', { exact: true }).fill(recipient.password);
|
||||
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||
await page.waitForURL('/signin');
|
||||
|
||||
await manualLogin({ page, email: recipient.email, password: recipient.password });
|
||||
await page.waitForURL('/documents');
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).not.toBeVisible();
|
||||
@ -154,11 +129,9 @@ test('[PR-711]: deleting a pending document should remove it from recipients', a
|
||||
await expect(page.getByText(/document.*cancelled/i).nth(0)).toBeVisible();
|
||||
|
||||
await page.goto('/documents');
|
||||
await page.waitForURL('/documents');
|
||||
|
||||
await page.getByTitle('Profile Dropdown').click();
|
||||
await page.getByRole('menuitem', { name: 'Sign Out' }).click();
|
||||
|
||||
await page.waitForURL('/signin');
|
||||
await manualSignout({ page });
|
||||
}
|
||||
});
|
||||
|
||||
@ -167,13 +140,7 @@ test('[PR-711]: deleting a draft document should remove it without additional pr
|
||||
}) => {
|
||||
const [sender] = TEST_USERS;
|
||||
|
||||
await page.goto('/signin');
|
||||
|
||||
// sign in
|
||||
await page.getByLabel('Email').fill(sender.email);
|
||||
await page.getByLabel('Password', { exact: true }).fill(sender.password);
|
||||
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||
|
||||
await manualLogin({ page, email: sender.email, password: sender.password });
|
||||
await page.waitForURL('/documents');
|
||||
|
||||
// open actions menu
|
||||
|
||||
@ -15,14 +15,8 @@ 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();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// signout
|
||||
await page.getByTitle('Profile Dropdown').click();
|
||||
await page.getByRole('menuitem', { name: 'Sign Out' }).click();
|
||||
});
|
||||
|
||||
test('[PR-713]: should see received documents', async ({ page }) => {
|
||||
@ -38,14 +32,8 @@ 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();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// signout
|
||||
await page.getByTitle('Profile Dropdown').click();
|
||||
await page.getByRole('menuitem', { name: 'Sign Out' }).click();
|
||||
});
|
||||
|
||||
test('[PR-713]: should be able to search by recipient', async ({ page }) => {
|
||||
@ -61,12 +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();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// signout
|
||||
await page.getByTitle('Profile Dropdown').click();
|
||||
await page.getByRole('menuitem', { name: 'Sign Out' }).click();
|
||||
});
|
||||
|
||||
87
packages/app-tests/e2e/teams/manage-team.spec.ts
Normal file
@ -0,0 +1,87 @@
|
||||
import { test } from '@playwright/test';
|
||||
|
||||
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
||||
import { seedTeam, unseedTeam } from '@documenso/prisma/seed/teams';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { manualLogin } from '../fixtures/authentication';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
test('[TEAMS]: create team', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
|
||||
await manualLogin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: '/settings/teams',
|
||||
});
|
||||
|
||||
const teamId = `team-${Date.now()}`;
|
||||
|
||||
// Create team.
|
||||
await page.getByRole('button', { name: 'Create team' }).click();
|
||||
await page.getByLabel('Team Name*').fill(teamId);
|
||||
await page.getByTestId('dialog-create-team-button').click();
|
||||
|
||||
await page.getByTestId('dialog-create-team-button').waitFor({ state: 'hidden' });
|
||||
|
||||
const isCheckoutRequired = page.url().includes('pending');
|
||||
test.skip(isCheckoutRequired, 'Test skipped because billing is enabled.');
|
||||
|
||||
// Goto new team settings page.
|
||||
await page.getByRole('row').filter({ hasText: teamId }).getByRole('link').nth(1).click();
|
||||
|
||||
await unseedTeam(teamId);
|
||||
});
|
||||
|
||||
test('[TEAMS]: delete team', async ({ page }) => {
|
||||
const team = await seedTeam();
|
||||
|
||||
await manualLogin({
|
||||
page,
|
||||
email: team.owner.email,
|
||||
redirectPath: `/t/${team.url}/settings`,
|
||||
});
|
||||
|
||||
// Delete team.
|
||||
await page.getByRole('button', { name: 'Delete team' }).click();
|
||||
await page.getByLabel(`Confirm by typing delete ${team.url}`).fill(`delete ${team.url}`);
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
// Check that we have been redirected to the teams page.
|
||||
await page.waitForURL(`${WEBAPP_BASE_URL}/settings/teams`);
|
||||
});
|
||||
|
||||
test('[TEAMS]: update team', async ({ page }) => {
|
||||
const team = await seedTeam();
|
||||
|
||||
await manualLogin({
|
||||
page,
|
||||
email: team.owner.email,
|
||||
});
|
||||
|
||||
// Navigate to create team page.
|
||||
await page.getByTestId('menu-switcher').click();
|
||||
await page.getByRole('menuitem', { name: 'Manage teams' }).click();
|
||||
|
||||
// Goto team settings page.
|
||||
await page.getByRole('row').filter({ hasText: team.url }).getByRole('link').nth(1).click();
|
||||
|
||||
const updatedTeamId = `team-${Date.now()}`;
|
||||
|
||||
// Update team.
|
||||
await page.getByLabel('Team Name*').click();
|
||||
await page.getByLabel('Team Name*').clear();
|
||||
await page.getByLabel('Team Name*').fill(updatedTeamId);
|
||||
await page.getByLabel('Team URL*').click();
|
||||
await page.getByLabel('Team URL*').clear();
|
||||
await page.getByLabel('Team URL*').fill(updatedTeamId);
|
||||
|
||||
await page.getByRole('button', { name: 'Update team' }).click();
|
||||
|
||||
// Check we have been redirected to the new team URL and the name is updated.
|
||||
await page.waitForURL(`${WEBAPP_BASE_URL}/t/${updatedTeamId}/settings`);
|
||||
|
||||
await unseedTeam(updatedTeamId);
|
||||
});
|
||||
282
packages/app-tests/e2e/teams/team-documents.spec.ts
Normal file
@ -0,0 +1,282 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { DocumentStatus } from '@documenso/prisma/client';
|
||||
import { seedDocuments, seedTeamDocuments } from '@documenso/prisma/seed/documents';
|
||||
import { seedTeamEmail, unseedTeam, unseedTeamEmail } from '@documenso/prisma/seed/teams';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { manualLogin, manualSignout } from '../fixtures/authentication';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
const checkDocumentTabCount = async (page: Page, tabName: string, count: number) => {
|
||||
await page.getByRole('tab', { name: tabName }).click();
|
||||
|
||||
if (tabName !== 'All') {
|
||||
await expect(page.getByRole('tab', { name: tabName })).toContainText(count.toString());
|
||||
}
|
||||
|
||||
if (count === 0) {
|
||||
await expect(page.getByRole('main')).toContainText(`Nothing to do`);
|
||||
return;
|
||||
}
|
||||
|
||||
await expect(page.getByRole('main')).toContainText(`Showing ${count}`);
|
||||
};
|
||||
|
||||
test('[TEAMS]: check team documents count', async ({ page }) => {
|
||||
const { team, teamMember2 } = await seedTeamDocuments();
|
||||
|
||||
// Run the test twice, once with the team owner and once with a team member to ensure the counts are the same.
|
||||
for (const user of [team.owner, teamMember2]) {
|
||||
await manualLogin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/documents`,
|
||||
});
|
||||
|
||||
// Check document counts.
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
await checkDocumentTabCount(page, 'Pending', 2);
|
||||
await checkDocumentTabCount(page, 'Completed', 1);
|
||||
await checkDocumentTabCount(page, 'Draft', 2);
|
||||
await checkDocumentTabCount(page, 'All', 5);
|
||||
|
||||
// Apply filter.
|
||||
await page.locator('button').filter({ hasText: 'Sender: All' }).click();
|
||||
await page.getByRole('option', { name: teamMember2.name ?? '' }).click();
|
||||
await page.waitForURL(/senderIds/);
|
||||
|
||||
// Check counts after filtering.
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
await checkDocumentTabCount(page, 'Pending', 2);
|
||||
await checkDocumentTabCount(page, 'Completed', 0);
|
||||
await checkDocumentTabCount(page, 'Draft', 1);
|
||||
await checkDocumentTabCount(page, 'All', 3);
|
||||
|
||||
await manualSignout({ page });
|
||||
}
|
||||
|
||||
await unseedTeam(team.url);
|
||||
});
|
||||
|
||||
test('[TEAMS]: check team documents count with internal team email', async ({ page }) => {
|
||||
const { team, teamMember2, teamMember4 } = await seedTeamDocuments();
|
||||
const { team: team2, teamMember2: team2Member2 } = await seedTeamDocuments();
|
||||
|
||||
const teamEmailMember = teamMember4;
|
||||
|
||||
await seedTeamEmail({
|
||||
email: teamEmailMember.email,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
const testUser1 = await seedUser();
|
||||
|
||||
await seedDocuments([
|
||||
// Documents sent from the team email account.
|
||||
{
|
||||
sender: teamEmailMember,
|
||||
recipients: [testUser1],
|
||||
type: DocumentStatus.COMPLETED,
|
||||
documentOptions: {
|
||||
teamId: team.id,
|
||||
},
|
||||
},
|
||||
{
|
||||
sender: teamEmailMember,
|
||||
recipients: [testUser1],
|
||||
type: DocumentStatus.PENDING,
|
||||
documentOptions: {
|
||||
teamId: team.id,
|
||||
},
|
||||
},
|
||||
{
|
||||
sender: teamMember4,
|
||||
recipients: [testUser1],
|
||||
type: DocumentStatus.DRAFT,
|
||||
},
|
||||
// Documents sent to the team email account.
|
||||
{
|
||||
sender: testUser1,
|
||||
recipients: [teamEmailMember],
|
||||
type: DocumentStatus.COMPLETED,
|
||||
},
|
||||
{
|
||||
sender: testUser1,
|
||||
recipients: [teamEmailMember],
|
||||
type: DocumentStatus.PENDING,
|
||||
},
|
||||
{
|
||||
sender: testUser1,
|
||||
recipients: [teamEmailMember],
|
||||
type: DocumentStatus.DRAFT,
|
||||
},
|
||||
// Document sent to the team email account from another team.
|
||||
{
|
||||
sender: team2Member2,
|
||||
recipients: [teamEmailMember],
|
||||
type: DocumentStatus.PENDING,
|
||||
documentOptions: {
|
||||
teamId: team2.id,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// Run the test twice, one with the team owner and once with the team member email to ensure the counts are the same.
|
||||
for (const user of [team.owner, teamEmailMember]) {
|
||||
await manualLogin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/documents`,
|
||||
});
|
||||
|
||||
// Check document counts.
|
||||
await checkDocumentTabCount(page, 'Inbox', 2);
|
||||
await checkDocumentTabCount(page, 'Pending', 3);
|
||||
await checkDocumentTabCount(page, 'Completed', 3);
|
||||
await checkDocumentTabCount(page, 'Draft', 3);
|
||||
await checkDocumentTabCount(page, 'All', 11);
|
||||
|
||||
// Apply filter.
|
||||
await page.locator('button').filter({ hasText: 'Sender: All' }).click();
|
||||
await page.getByRole('option', { name: teamMember2.name ?? '' }).click();
|
||||
await page.waitForURL(/senderIds/);
|
||||
|
||||
// Check counts after filtering.
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
await checkDocumentTabCount(page, 'Pending', 2);
|
||||
await checkDocumentTabCount(page, 'Completed', 0);
|
||||
await checkDocumentTabCount(page, 'Draft', 1);
|
||||
await checkDocumentTabCount(page, 'All', 3);
|
||||
|
||||
await manualSignout({ page });
|
||||
}
|
||||
|
||||
await unseedTeamEmail({ teamId: team.id });
|
||||
await unseedTeam(team.url);
|
||||
});
|
||||
|
||||
test('[TEAMS]: check team documents count with external team email', async ({ page }) => {
|
||||
const { team, teamMember2 } = await seedTeamDocuments();
|
||||
const { team: team2, teamMember2: team2Member2 } = await seedTeamDocuments();
|
||||
|
||||
const teamEmail = `external-team-email-${team.id}@test.documenso.com`;
|
||||
|
||||
await seedTeamEmail({
|
||||
email: teamEmail,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
const testUser1 = await seedUser();
|
||||
|
||||
await seedDocuments([
|
||||
// Documents sent to the team email account.
|
||||
{
|
||||
sender: testUser1,
|
||||
recipients: [teamEmail],
|
||||
type: DocumentStatus.COMPLETED,
|
||||
},
|
||||
{
|
||||
sender: testUser1,
|
||||
recipients: [teamEmail],
|
||||
type: DocumentStatus.PENDING,
|
||||
},
|
||||
{
|
||||
sender: testUser1,
|
||||
recipients: [teamEmail],
|
||||
type: DocumentStatus.DRAFT,
|
||||
},
|
||||
// Document sent to the team email account from another team.
|
||||
{
|
||||
sender: team2Member2,
|
||||
recipients: [teamEmail],
|
||||
type: DocumentStatus.PENDING,
|
||||
documentOptions: {
|
||||
teamId: team2.id,
|
||||
},
|
||||
},
|
||||
// Document sent to the team email account from an individual user.
|
||||
{
|
||||
sender: testUser1,
|
||||
recipients: [teamEmail],
|
||||
type: DocumentStatus.PENDING,
|
||||
documentOptions: {
|
||||
teamId: team2.id,
|
||||
},
|
||||
},
|
||||
{
|
||||
sender: testUser1,
|
||||
recipients: [teamEmail],
|
||||
type: DocumentStatus.DRAFT,
|
||||
documentOptions: {
|
||||
teamId: team2.id,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
await manualLogin({
|
||||
page,
|
||||
email: teamMember2.email,
|
||||
redirectPath: `/t/${team.url}/documents`,
|
||||
});
|
||||
|
||||
// Check document counts.
|
||||
await checkDocumentTabCount(page, 'Inbox', 3);
|
||||
await checkDocumentTabCount(page, 'Pending', 2);
|
||||
await checkDocumentTabCount(page, 'Completed', 2);
|
||||
await checkDocumentTabCount(page, 'Draft', 2);
|
||||
await checkDocumentTabCount(page, 'All', 9);
|
||||
|
||||
// Apply filter.
|
||||
await page.locator('button').filter({ hasText: 'Sender: All' }).click();
|
||||
await page.getByRole('option', { name: teamMember2.name ?? '' }).click();
|
||||
await page.waitForURL(/senderIds/);
|
||||
|
||||
// Check counts after filtering.
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
await checkDocumentTabCount(page, 'Pending', 2);
|
||||
await checkDocumentTabCount(page, 'Completed', 0);
|
||||
await checkDocumentTabCount(page, 'Draft', 1);
|
||||
await checkDocumentTabCount(page, 'All', 3);
|
||||
|
||||
await unseedTeamEmail({ teamId: team.id });
|
||||
await unseedTeam(team.url);
|
||||
});
|
||||
|
||||
test('[TEAMS]: delete pending team document', async ({ page }) => {
|
||||
const { team, teamMember2: currentUser } = await seedTeamDocuments();
|
||||
|
||||
await manualLogin({
|
||||
page,
|
||||
email: currentUser.email,
|
||||
redirectPath: `/t/${team.url}/documents?status=PENDING`,
|
||||
});
|
||||
|
||||
await page.getByRole('row').getByRole('button').nth(1).click();
|
||||
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await checkDocumentTabCount(page, 'Pending', 1);
|
||||
});
|
||||
|
||||
test('[TEAMS]: resend pending team document', async ({ page }) => {
|
||||
const { team, teamMember2: currentUser } = await seedTeamDocuments();
|
||||
|
||||
await manualLogin({
|
||||
page,
|
||||
email: currentUser.email,
|
||||
redirectPath: `/t/${team.url}/documents?status=PENDING`,
|
||||
});
|
||||
|
||||
await page.getByRole('row').getByRole('button').nth(1).click();
|
||||
await page.getByRole('menuitem', { name: 'Resend' }).click();
|
||||
|
||||
await page.getByLabel('test.documenso.com').first().click();
|
||||
await page.getByRole('button', { name: 'Send reminder' }).click();
|
||||
|
||||
await expect(page.getByRole('status')).toContainText('Document re-sent');
|
||||
});
|
||||
102
packages/app-tests/e2e/teams/team-email.spec.ts
Normal file
@ -0,0 +1,102 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
||||
import { seedTeam, seedTeamEmailVerification, unseedTeam } from '@documenso/prisma/seed/teams';
|
||||
import { seedUser, unseedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { manualLogin } from '../fixtures/authentication';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
test('[TEAMS]: send team email request', async ({ page }) => {
|
||||
const team = await seedTeam();
|
||||
|
||||
await manualLogin({
|
||||
page,
|
||||
email: team.owner.email,
|
||||
password: 'password',
|
||||
redirectPath: `/t/${team.url}/settings`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Add email' }).click();
|
||||
await page.getByPlaceholder('eg. Legal').click();
|
||||
await page.getByPlaceholder('eg. Legal').fill('test@test.documenso.com');
|
||||
await page.getByPlaceholder('example@example.com').click();
|
||||
await page.getByPlaceholder('example@example.com').fill('test@test.documenso.com');
|
||||
await page.getByRole('button', { name: 'Add' }).click();
|
||||
|
||||
await expect(
|
||||
page
|
||||
.getByRole('status')
|
||||
.filter({ hasText: 'We have sent a confirmation email for verification.' })
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
|
||||
await unseedTeam(team.url);
|
||||
});
|
||||
|
||||
test('[TEAMS]: accept team email request', async ({ page }) => {
|
||||
const team = await seedTeam({
|
||||
createTeamMembers: 1,
|
||||
});
|
||||
|
||||
const teamEmailVerification = await seedTeamEmailVerification({
|
||||
email: 'team-email-verification@test.documenso.com',
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
await page.goto(`${WEBAPP_BASE_URL}/team/verify/email/${teamEmailVerification.token}`);
|
||||
await expect(page.getByRole('heading')).toContainText('Team email verified!');
|
||||
|
||||
await unseedTeam(team.url);
|
||||
});
|
||||
|
||||
test('[TEAMS]: delete team email', async ({ page }) => {
|
||||
const team = await seedTeam({
|
||||
createTeamMembers: 1,
|
||||
createTeamEmail: true,
|
||||
});
|
||||
|
||||
await manualLogin({
|
||||
page,
|
||||
email: team.owner.email,
|
||||
redirectPath: `/t/${team.url}/settings`,
|
||||
});
|
||||
|
||||
await page.locator('section div').filter({ hasText: 'Team email' }).getByRole('button').click();
|
||||
|
||||
await page.getByRole('menuitem', { name: 'Remove' }).click();
|
||||
|
||||
await expect(page.getByText('Team email has been removed').first()).toBeVisible();
|
||||
|
||||
await unseedTeam(team.url);
|
||||
});
|
||||
|
||||
test('[TEAMS]: team email owner removes access', async ({ page }) => {
|
||||
const team = await seedTeam({
|
||||
createTeamMembers: 1,
|
||||
createTeamEmail: true,
|
||||
});
|
||||
|
||||
if (!team.teamEmail) {
|
||||
throw new Error('Not possible');
|
||||
}
|
||||
|
||||
const teamEmailOwner = await seedUser({
|
||||
email: team.teamEmail.email,
|
||||
});
|
||||
|
||||
await manualLogin({
|
||||
page,
|
||||
email: teamEmailOwner.email,
|
||||
redirectPath: `/settings/teams`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Revoke access' }).click();
|
||||
await page.getByRole('button', { name: 'Revoke' }).click();
|
||||
|
||||
await expect(page.getByText('You have successfully revoked').first()).toBeVisible();
|
||||
|
||||
await unseedTeam(team.url);
|
||||
await unseedUser(teamEmailOwner.id);
|
||||
});
|
||||
110
packages/app-tests/e2e/teams/team-members.spec.ts
Normal file
@ -0,0 +1,110 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
||||
import { seedTeam, seedTeamInvite, unseedTeam } from '@documenso/prisma/seed/teams';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { manualLogin } from '../fixtures/authentication';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
test('[TEAMS]: update team member role', async ({ page }) => {
|
||||
const team = await seedTeam({
|
||||
createTeamMembers: 1,
|
||||
});
|
||||
|
||||
await manualLogin({
|
||||
page,
|
||||
email: team.owner.email,
|
||||
password: 'password',
|
||||
redirectPath: `/t/${team.url}/settings/members`,
|
||||
});
|
||||
|
||||
const teamMemberToUpdate = team.members[1];
|
||||
|
||||
await page
|
||||
.getByRole('row')
|
||||
.filter({ hasText: teamMemberToUpdate.user.email })
|
||||
.getByRole('button')
|
||||
.click();
|
||||
|
||||
await page.getByRole('menuitem', { name: 'Update role' }).click();
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByLabel('Manager').click();
|
||||
await page.getByRole('button', { name: 'Update' }).click();
|
||||
await expect(
|
||||
page.getByRole('row').filter({ hasText: teamMemberToUpdate.user.email }),
|
||||
).toContainText('Manager');
|
||||
|
||||
await unseedTeam(team.url);
|
||||
});
|
||||
|
||||
test('[TEAMS]: accept team invitation without account', async ({ page }) => {
|
||||
const team = await seedTeam();
|
||||
|
||||
const teamInvite = await seedTeamInvite({
|
||||
email: `team-invite-test-${Date.now()}@test.documenso.com`,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
await page.goto(`${WEBAPP_BASE_URL}/team/invite/${teamInvite.token}`);
|
||||
await expect(page.getByRole('heading')).toContainText('Team invitation');
|
||||
|
||||
await unseedTeam(team.url);
|
||||
});
|
||||
|
||||
test('[TEAMS]: accept team invitation with account', async ({ page }) => {
|
||||
const team = await seedTeam();
|
||||
const user = await seedUser();
|
||||
|
||||
const teamInvite = await seedTeamInvite({
|
||||
email: user.email,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
await page.goto(`${WEBAPP_BASE_URL}/team/invite/${teamInvite.token}`);
|
||||
await expect(page.getByRole('heading')).toContainText('Invitation accepted!');
|
||||
|
||||
await unseedTeam(team.url);
|
||||
});
|
||||
|
||||
test('[TEAMS]: member can leave team', async ({ page }) => {
|
||||
const team = await seedTeam({
|
||||
createTeamMembers: 1,
|
||||
});
|
||||
|
||||
const teamMember = team.members[1];
|
||||
|
||||
await manualLogin({
|
||||
page,
|
||||
email: teamMember.user.email,
|
||||
password: 'password',
|
||||
redirectPath: `/settings/teams`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Leave' }).click();
|
||||
await page.getByRole('button', { name: 'Leave' }).click();
|
||||
|
||||
await expect(page.getByRole('status').first()).toContainText(
|
||||
'You have successfully left this team.',
|
||||
);
|
||||
|
||||
await unseedTeam(team.url);
|
||||
});
|
||||
|
||||
test('[TEAMS]: owner cannot leave team', async ({ page }) => {
|
||||
const team = await seedTeam({
|
||||
createTeamMembers: 1,
|
||||
});
|
||||
|
||||
await manualLogin({
|
||||
page,
|
||||
email: team.owner.email,
|
||||
password: 'password',
|
||||
redirectPath: `/settings/teams`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('button').getByText('Leave')).toBeDisabled();
|
||||
|
||||
await unseedTeam(team.url);
|
||||
});
|
||||
69
packages/app-tests/e2e/teams/transfer-team.spec.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
||||
import { seedTeam, seedTeamTransfer, unseedTeam } from '@documenso/prisma/seed/teams';
|
||||
|
||||
import { manualLogin } from '../fixtures/authentication';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
test('[TEAMS]: initiate and cancel team transfer', async ({ page }) => {
|
||||
const team = await seedTeam({
|
||||
createTeamMembers: 1,
|
||||
});
|
||||
|
||||
const teamMember = team.members[1];
|
||||
|
||||
await manualLogin({
|
||||
page,
|
||||
email: team.owner.email,
|
||||
password: 'password',
|
||||
redirectPath: `/t/${team.url}/settings`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Transfer team' }).click();
|
||||
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByLabel(teamMember.user.name ?? '').click();
|
||||
await page.getByLabel('Confirm by typing transfer').click();
|
||||
await page.getByLabel('Confirm by typing transfer').fill('transfer');
|
||||
await page.getByRole('button', { name: 'Transfer' }).click();
|
||||
|
||||
await expect(page.locator('[id="\\:r2\\:-form-item-message"]')).toContainText(
|
||||
`You must enter 'transfer ${team.name}' to proceed`,
|
||||
);
|
||||
|
||||
await page.getByLabel('Confirm by typing transfer').click();
|
||||
await page.getByLabel('Confirm by typing transfer').fill(`transfer ${team.name}`);
|
||||
await page.getByRole('button', { name: 'Transfer' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Team transfer in progress' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
|
||||
await expect(page.getByRole('status').first()).toContainText(
|
||||
'The team transfer invitation has been successfully deleted.',
|
||||
);
|
||||
|
||||
await unseedTeam(team.url);
|
||||
});
|
||||
|
||||
/**
|
||||
* Current skipped until we disable billing during tests.
|
||||
*/
|
||||
test.skip('[TEAMS]: accept team transfer', async ({ page }) => {
|
||||
const team = await seedTeam({
|
||||
createTeamMembers: 1,
|
||||
});
|
||||
|
||||
const newOwnerMember = team.members[1];
|
||||
|
||||
const teamTransferRequest = await seedTeamTransfer({
|
||||
teamId: team.id,
|
||||
newOwnerUserId: newOwnerMember.userId,
|
||||
});
|
||||
|
||||
await page.goto(`${WEBAPP_BASE_URL}/team/verify/transfer/${teamTransferRequest.token}`);
|
||||
await expect(page.getByRole('heading')).toContainText('Team ownership transferred!');
|
||||
|
||||
await unseedTeam(team.url);
|
||||
});
|
||||
210
packages/app-tests/e2e/templates/manage-templates.spec.ts
Normal file
@ -0,0 +1,210 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
||||
import { seedTeam, unseedTeam } from '@documenso/prisma/seed/teams';
|
||||
import { seedTemplate } from '@documenso/prisma/seed/templates';
|
||||
|
||||
import { manualLogin } from '../fixtures/authentication';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
test('[TEMPLATES]: view templates', async ({ page }) => {
|
||||
const team = await seedTeam({
|
||||
createTeamMembers: 1,
|
||||
});
|
||||
|
||||
const owner = team.owner;
|
||||
const teamMemberUser = team.members[1].user;
|
||||
|
||||
// Should only be visible to the owner in personal templates.
|
||||
await seedTemplate({
|
||||
title: 'Personal template',
|
||||
userId: owner.id,
|
||||
});
|
||||
|
||||
// Should be visible to team members.
|
||||
await seedTemplate({
|
||||
title: 'Team template 1',
|
||||
userId: owner.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
// Should be visible to team members.
|
||||
await seedTemplate({
|
||||
title: 'Team template 2',
|
||||
userId: teamMemberUser.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
await manualLogin({
|
||||
page,
|
||||
email: owner.email,
|
||||
redirectPath: '/templates',
|
||||
});
|
||||
|
||||
// Owner should see both team templates.
|
||||
await page.goto(`${WEBAPP_BASE_URL}/t/${team.url}/templates`);
|
||||
await expect(page.getByRole('main')).toContainText('Showing 2 results');
|
||||
|
||||
// Only should only see their personal template.
|
||||
await page.goto(`${WEBAPP_BASE_URL}/templates`);
|
||||
await expect(page.getByRole('main')).toContainText('Showing 1 result');
|
||||
|
||||
await unseedTeam(team.url);
|
||||
});
|
||||
|
||||
test('[TEMPLATES]: delete template', async ({ page }) => {
|
||||
const team = await seedTeam({
|
||||
createTeamMembers: 1,
|
||||
});
|
||||
|
||||
const owner = team.owner;
|
||||
const teamMemberUser = team.members[1].user;
|
||||
|
||||
// Should only be visible to the owner in personal templates.
|
||||
await seedTemplate({
|
||||
title: 'Personal template',
|
||||
userId: owner.id,
|
||||
});
|
||||
|
||||
// Should be visible to team members.
|
||||
await seedTemplate({
|
||||
title: 'Team template 1',
|
||||
userId: owner.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
// Should be visible to team members.
|
||||
await seedTemplate({
|
||||
title: 'Team template 2',
|
||||
userId: teamMemberUser.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
await manualLogin({
|
||||
page,
|
||||
email: owner.email,
|
||||
redirectPath: '/templates',
|
||||
});
|
||||
|
||||
// Owner should be able to delete their personal template.
|
||||
await page.getByRole('cell', { name: 'Use Template' }).getByRole('button').nth(1).click();
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
await expect(page.getByText('Template deleted').first()).toBeVisible();
|
||||
|
||||
// Team member should be able to delete all templates.
|
||||
await page.goto(`${WEBAPP_BASE_URL}/t/${team.url}/templates`);
|
||||
|
||||
for (const template of ['Team template 1', 'Team template 2']) {
|
||||
await page
|
||||
.getByRole('row', { name: template })
|
||||
.getByRole('cell', { name: 'Use Template' })
|
||||
.getByRole('button')
|
||||
.nth(1)
|
||||
.click();
|
||||
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
await expect(page.getByText('Template deleted').first()).toBeVisible();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
await unseedTeam(team.url);
|
||||
});
|
||||
|
||||
test('[TEMPLATES]: duplicate template', async ({ page }) => {
|
||||
const team = await seedTeam({
|
||||
createTeamMembers: 1,
|
||||
});
|
||||
|
||||
const owner = team.owner;
|
||||
const teamMemberUser = team.members[1].user;
|
||||
|
||||
// Should only be visible to the owner in personal templates.
|
||||
await seedTemplate({
|
||||
title: 'Personal template',
|
||||
userId: owner.id,
|
||||
});
|
||||
|
||||
// Should be visible to team members.
|
||||
await seedTemplate({
|
||||
title: 'Team template 1',
|
||||
userId: teamMemberUser.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
await manualLogin({
|
||||
page,
|
||||
email: owner.email,
|
||||
redirectPath: '/templates',
|
||||
});
|
||||
|
||||
// Duplicate personal template.
|
||||
await page.getByRole('cell', { name: 'Use Template' }).getByRole('button').nth(1).click();
|
||||
await page.getByRole('menuitem', { name: 'Duplicate' }).click();
|
||||
await page.getByRole('button', { name: 'Duplicate' }).click();
|
||||
await expect(page.getByText('Template duplicated').first()).toBeVisible();
|
||||
await expect(page.getByRole('main')).toContainText('Showing 2 results');
|
||||
|
||||
await page.goto(`${WEBAPP_BASE_URL}/t/${team.url}/templates`);
|
||||
|
||||
// Duplicate team template.
|
||||
await page.getByRole('cell', { name: 'Use Template' }).getByRole('button').nth(1).click();
|
||||
await page.getByRole('menuitem', { name: 'Duplicate' }).click();
|
||||
await page.getByRole('button', { name: 'Duplicate' }).click();
|
||||
await expect(page.getByText('Template duplicated').first()).toBeVisible();
|
||||
await expect(page.getByRole('main')).toContainText('Showing 2 results');
|
||||
|
||||
await unseedTeam(team.url);
|
||||
});
|
||||
|
||||
test('[TEMPLATES]: use template', async ({ page }) => {
|
||||
const team = await seedTeam({
|
||||
createTeamMembers: 1,
|
||||
});
|
||||
|
||||
const owner = team.owner;
|
||||
const teamMemberUser = team.members[1].user;
|
||||
|
||||
// Should only be visible to the owner in personal templates.
|
||||
await seedTemplate({
|
||||
title: 'Personal template',
|
||||
userId: owner.id,
|
||||
});
|
||||
|
||||
// Should be visible to team members.
|
||||
await seedTemplate({
|
||||
title: 'Team template 1',
|
||||
userId: teamMemberUser.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
await manualLogin({
|
||||
page,
|
||||
email: owner.email,
|
||||
redirectPath: '/templates',
|
||||
});
|
||||
|
||||
// Use personal template.
|
||||
await page.getByRole('button', { name: 'Use Template' }).click();
|
||||
await page.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`);
|
||||
await expect(page.getByRole('main')).toContainText('Showing 1 result');
|
||||
|
||||
await unseedTeam(team.url);
|
||||
});
|
||||
@ -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' }).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);
|
||||
});
|
||||
|
||||
BIN
packages/assets/images/background-lw-2.png
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
packages/assets/images/community-cards.png
Normal file
|
After Width: | Height: | Size: 4.5 MiB |
BIN
packages/assets/images/profile-claim-teaser.png
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
packages/assets/images/timur.png
Normal file
|
After Width: | Height: | Size: 82 KiB |
@ -1,16 +1,22 @@
|
||||
import { APP_BASE_URL } from '@documenso/lib/constants/app';
|
||||
|
||||
import { FREE_PLAN_LIMITS } from './constants';
|
||||
import { TLimitsResponseSchema, ZLimitsResponseSchema } from './schema';
|
||||
import type { TLimitsResponseSchema } from './schema';
|
||||
import { ZLimitsResponseSchema } from './schema';
|
||||
|
||||
export type GetLimitsOptions = {
|
||||
headers?: Record<string, string>;
|
||||
teamId?: number | null;
|
||||
};
|
||||
|
||||
export const getLimits = async ({ headers }: GetLimitsOptions = {}) => {
|
||||
export const getLimits = async ({ headers, teamId }: GetLimitsOptions = {}) => {
|
||||
const requestHeaders = headers ?? {};
|
||||
|
||||
const url = new URL(`${APP_BASE_URL}/api/limits`);
|
||||
const url = new URL('/api/limits', APP_BASE_URL() ?? 'http://localhost:3000');
|
||||
|
||||
if (teamId) {
|
||||
requestHeaders['team-id'] = teamId.toString();
|
||||
}
|
||||
|
||||
return fetch(url, {
|
||||
headers: {
|
||||
|
||||
@ -1,10 +1,15 @@
|
||||
import { TLimitsSchema } from './schema';
|
||||
import type { TLimitsSchema } from './schema';
|
||||
|
||||
export const FREE_PLAN_LIMITS: TLimitsSchema = {
|
||||
documents: 5,
|
||||
recipients: 10,
|
||||
};
|
||||
|
||||
export const TEAM_PLAN_LIMITS: TLimitsSchema = {
|
||||
documents: Infinity,
|
||||
recipients: Infinity,
|
||||
};
|
||||
|
||||
export const SELFHOSTED_PLAN_LIMITS: TLimitsSchema = {
|
||||
documents: Infinity,
|
||||
recipients: Infinity,
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
import { getToken } from 'next-auth/jwt';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { ERROR_CODES } from './errors';
|
||||
import { TLimitsErrorResponseSchema, TLimitsResponseSchema } from './schema';
|
||||
import type { TLimitsErrorResponseSchema, TLimitsResponseSchema } from './schema';
|
||||
import { getServerLimits } from './server';
|
||||
|
||||
export const limitsHandler = async (
|
||||
@ -14,7 +14,19 @@ export const limitsHandler = async (
|
||||
try {
|
||||
const token = await getToken({ req });
|
||||
|
||||
const limits = await getServerLimits({ email: token?.email });
|
||||
const rawTeamId = req.headers['team-id'];
|
||||
|
||||
let teamId: number | null = null;
|
||||
|
||||
if (typeof rawTeamId === 'string' && !isNaN(parseInt(rawTeamId, 10))) {
|
||||
teamId = parseInt(rawTeamId, 10);
|
||||
}
|
||||
|
||||
if (!teamId && rawTeamId) {
|
||||
throw new Error(ERROR_CODES.INVALID_TEAM_ID);
|
||||
}
|
||||
|
||||
const limits = await getServerLimits({ email: token?.email, teamId });
|
||||
|
||||
return res.status(200).json(limits);
|
||||
} catch (err) {
|
||||
|
||||
@ -6,7 +6,7 @@ import { equals } from 'remeda';
|
||||
|
||||
import { getLimits } from '../client';
|
||||
import { FREE_PLAN_LIMITS } from '../constants';
|
||||
import { TLimitsResponseSchema } from '../schema';
|
||||
import type { TLimitsResponseSchema } from '../schema';
|
||||
|
||||
export type LimitsContextValue = TLimitsResponseSchema;
|
||||
|
||||
@ -24,19 +24,22 @@ export const useLimits = () => {
|
||||
|
||||
export type LimitsProviderProps = {
|
||||
initialValue?: LimitsContextValue;
|
||||
teamId?: number;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const LimitsProvider = ({ initialValue, children }: LimitsProviderProps) => {
|
||||
const defaultValue: TLimitsResponseSchema = {
|
||||
export const LimitsProvider = ({
|
||||
initialValue = {
|
||||
quota: FREE_PLAN_LIMITS,
|
||||
remaining: FREE_PLAN_LIMITS,
|
||||
};
|
||||
|
||||
const [limits, setLimits] = useState(() => initialValue ?? defaultValue);
|
||||
},
|
||||
teamId,
|
||||
children,
|
||||
}: LimitsProviderProps) => {
|
||||
const [limits, setLimits] = useState(() => initialValue);
|
||||
|
||||
const refreshLimits = async () => {
|
||||
const newLimits = await getLimits();
|
||||
const newLimits = await getLimits({ teamId });
|
||||
|
||||
setLimits((oldLimits) => {
|
||||
if (equals(oldLimits, newLimits)) {
|
||||
|
||||
@ -3,16 +3,22 @@
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
import { getLimits } from '../client';
|
||||
import type { LimitsContextValue } from './client';
|
||||
import { LimitsProvider as ClientLimitsProvider } from './client';
|
||||
|
||||
export type LimitsProviderProps = {
|
||||
children?: React.ReactNode;
|
||||
teamId?: number;
|
||||
};
|
||||
|
||||
export const LimitsProvider = async ({ children }: LimitsProviderProps) => {
|
||||
export const LimitsProvider = async ({ children, teamId }: LimitsProviderProps) => {
|
||||
const requestHeaders = Object.fromEntries(headers().entries());
|
||||
|
||||
const limits = await getLimits({ headers: requestHeaders });
|
||||
const limits: LimitsContextValue = await getLimits({ headers: requestHeaders, teamId });
|
||||
|
||||
return <ClientLimitsProvider initialValue={limits}>{children}</ClientLimitsProvider>;
|
||||
return (
|
||||
<ClientLimitsProvider initialValue={limits} teamId={teamId}>
|
||||
{children}
|
||||
</ClientLimitsProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,22 +1,21 @@
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { getFlag } from '@documenso/lib/universal/get-feature-flag';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { SubscriptionStatus } from '@documenso/prisma/client';
|
||||
|
||||
import { getPricesByType } from '../stripe/get-prices-by-type';
|
||||
import { FREE_PLAN_LIMITS, SELFHOSTED_PLAN_LIMITS } from './constants';
|
||||
import { getDocumentRelatedPrices } from '../stripe/get-document-related-prices.ts';
|
||||
import { FREE_PLAN_LIMITS, SELFHOSTED_PLAN_LIMITS, TEAM_PLAN_LIMITS } from './constants';
|
||||
import { ERROR_CODES } from './errors';
|
||||
import { ZLimitsSchema } from './schema';
|
||||
|
||||
export type GetServerLimitsOptions = {
|
||||
email?: string | null;
|
||||
teamId?: number | null;
|
||||
};
|
||||
|
||||
export const getServerLimits = async ({ email }: GetServerLimitsOptions) => {
|
||||
const isBillingEnabled = await getFlag('app_billing');
|
||||
|
||||
if (!isBillingEnabled) {
|
||||
export const getServerLimits = async ({ email, teamId }: GetServerLimitsOptions) => {
|
||||
if (!IS_BILLING_ENABLED()) {
|
||||
return {
|
||||
quota: SELFHOSTED_PLAN_LIMITS,
|
||||
remaining: SELFHOSTED_PLAN_LIMITS,
|
||||
@ -27,6 +26,14 @@ export const getServerLimits = async ({ email }: GetServerLimitsOptions) => {
|
||||
throw new Error(ERROR_CODES.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
return teamId ? handleTeamLimits({ email, teamId }) : handleUserLimits({ email });
|
||||
};
|
||||
|
||||
type HandleUserLimitsOptions = {
|
||||
email: string;
|
||||
};
|
||||
|
||||
const handleUserLimits = async ({ email }: HandleUserLimitsOptions) => {
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
email,
|
||||
@ -48,10 +55,11 @@ export const getServerLimits = async ({ email }: GetServerLimitsOptions) => {
|
||||
);
|
||||
|
||||
if (activeSubscriptions.length > 0) {
|
||||
const individualPrices = await getPricesByType('individual');
|
||||
const documentPlanPrices = await getDocumentRelatedPrices();
|
||||
|
||||
for (const subscription of activeSubscriptions) {
|
||||
const price = individualPrices.find((price) => price.id === subscription.priceId);
|
||||
const price = documentPlanPrices.find((price) => price.id === subscription.priceId);
|
||||
|
||||
if (!price || typeof price.product === 'string' || price.product.deleted) {
|
||||
continue;
|
||||
}
|
||||
@ -71,6 +79,7 @@ export const getServerLimits = async ({ email }: GetServerLimitsOptions) => {
|
||||
const documents = await prisma.document.count({
|
||||
where: {
|
||||
userId: user.id,
|
||||
teamId: null,
|
||||
createdAt: {
|
||||
gte: DateTime.utc().startOf('month').toJSDate(),
|
||||
},
|
||||
@ -84,3 +93,50 @@ export const getServerLimits = async ({ email }: GetServerLimitsOptions) => {
|
||||
remaining,
|
||||
};
|
||||
};
|
||||
|
||||
type HandleTeamLimitsOptions = {
|
||||
email: string;
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
const handleTeamLimits = async ({ email, teamId }: HandleTeamLimitsOptions) => {
|
||||
const team = await prisma.team.findFirst({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
user: {
|
||||
email,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
subscription: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
throw new Error('Team not found');
|
||||
}
|
||||
|
||||
const { subscription } = team;
|
||||
|
||||
if (subscription && subscription.status === SubscriptionStatus.INACTIVE) {
|
||||
return {
|
||||
quota: {
|
||||
documents: 0,
|
||||
recipients: 0,
|
||||
},
|
||||
remaining: {
|
||||
documents: 0,
|
||||
recipients: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
quota: structuredClone(TEAM_PLAN_LIMITS),
|
||||
remaining: structuredClone(TEAM_PLAN_LIMITS),
|
||||
};
|
||||
};
|
||||
|
||||
20
packages/ee/server-only/stripe/create-team-customer.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { STRIPE_CUSTOMER_TYPE } from '@documenso/lib/constants/billing';
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
|
||||
type CreateTeamCustomerOptions = {
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a Stripe customer for a given team.
|
||||
*/
|
||||
export const createTeamCustomer = async ({ name, email }: CreateTeamCustomerOptions) => {
|
||||
return await stripe.customers.create({
|
||||
name,
|
||||
email,
|
||||
metadata: {
|
||||
type: STRIPE_CUSTOMER_TYPE.TEAM,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,22 @@
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
|
||||
type DeleteCustomerPaymentMethodsOptions = {
|
||||
customerId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete all attached payment methods for a given customer.
|
||||
*/
|
||||
export const deleteCustomerPaymentMethods = async ({
|
||||
customerId,
|
||||
}: DeleteCustomerPaymentMethodsOptions) => {
|
||||
const paymentMethods = await stripe.paymentMethods.list({
|
||||
customer: customerId,
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
paymentMethods.data.map(async (paymentMethod) =>
|
||||
stripe.paymentMethods.detach(paymentMethod.id),
|
||||
),
|
||||
);
|
||||
};
|
||||
@ -1,17 +1,21 @@
|
||||
'use server';
|
||||
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
|
||||
export type GetCheckoutSessionOptions = {
|
||||
customerId: string;
|
||||
priceId: string;
|
||||
returnUrl: string;
|
||||
subscriptionMetadata?: Stripe.Metadata;
|
||||
};
|
||||
|
||||
export const getCheckoutSession = async ({
|
||||
customerId,
|
||||
priceId,
|
||||
returnUrl,
|
||||
subscriptionMetadata,
|
||||
}: GetCheckoutSessionOptions) => {
|
||||
'use server';
|
||||
|
||||
@ -26,6 +30,9 @@ export const getCheckoutSession = async ({
|
||||
],
|
||||
success_url: `${returnUrl}?success=true`,
|
||||
cancel_url: `${returnUrl}?canceled=true`,
|
||||
subscription_data: {
|
||||
metadata: subscriptionMetadata,
|
||||
},
|
||||
});
|
||||
|
||||
return session.url;
|
||||
|
||||
13
packages/ee/server-only/stripe/get-community-plan-prices.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
||||
|
||||
import { getPricesByPlan } from './get-prices-by-plan';
|
||||
|
||||
export const getCommunityPlanPrices = async () => {
|
||||
return await getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY);
|
||||
};
|
||||
|
||||
export const getCommunityPlanPriceIds = async () => {
|
||||
const prices = await getCommunityPlanPrices();
|
||||
|
||||
return prices.map((price) => price.id);
|
||||
};
|
||||
@ -1,15 +1,19 @@
|
||||
import { STRIPE_CUSTOMER_TYPE } from '@documenso/lib/constants/billing';
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { User } from '@documenso/prisma/client';
|
||||
|
||||
import { onSubscriptionUpdated } from './webhook/on-subscription-updated';
|
||||
|
||||
/**
|
||||
* Get a non team Stripe customer by email.
|
||||
*/
|
||||
export const getStripeCustomerByEmail = async (email: string) => {
|
||||
const foundStripeCustomers = await stripe.customers.list({
|
||||
email,
|
||||
});
|
||||
|
||||
return foundStripeCustomers.data[0] ?? null;
|
||||
return foundStripeCustomers.data.find((customer) => customer.metadata.type !== 'team') ?? null;
|
||||
};
|
||||
|
||||
export const getStripeCustomerById = async (stripeCustomerId: string) => {
|
||||
@ -51,6 +55,7 @@ export const getStripeCustomerByUser = async (user: User) => {
|
||||
email: user.email,
|
||||
metadata: {
|
||||
userId: user.id,
|
||||
type: STRIPE_CUSTOMER_TYPE.INDIVIDUAL,
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -78,6 +83,14 @@ export const getStripeCustomerByUser = async (user: User) => {
|
||||
};
|
||||
};
|
||||
|
||||
export const getStripeCustomerIdByUser = async (user: User) => {
|
||||
if (user.customerId !== null) {
|
||||
return user.customerId;
|
||||
}
|
||||
|
||||
return await getStripeCustomerByUser(user).then((session) => session.stripeCustomer.id);
|
||||
};
|
||||
|
||||
const syncStripeCustomerSubscriptions = async (userId: number, stripeCustomerId: string) => {
|
||||
const stripeSubscriptions = await stripe.subscriptions.list({
|
||||
customer: stripeCustomerId,
|
||||
|
||||
@ -0,0 +1,10 @@
|
||||
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
||||
|
||||
import { getPricesByPlan } from './get-prices-by-plan';
|
||||
|
||||
/**
|
||||
* Returns the Stripe prices of items that affect the amount of documents a user can create.
|
||||
*/
|
||||
export const getDocumentRelatedPrices = async () => {
|
||||
return await getPricesByPlan([STRIPE_PLAN_TYPE.COMMUNITY, STRIPE_PLAN_TYPE.ENTERPRISE]);
|
||||
};
|
||||
13
packages/ee/server-only/stripe/get-enterprise-plan-prices.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
||||
|
||||
import { getPricesByPlan } from './get-prices-by-plan';
|
||||
|
||||
export const getEnterprisePlanPrices = async () => {
|
||||
return await getPricesByPlan(STRIPE_PLAN_TYPE.ENTERPRISE);
|
||||
};
|
||||
|
||||
export const getEnterprisePlanPriceIds = async () => {
|
||||
const prices = await getEnterprisePlanPrices();
|
||||
|
||||
return prices.map((price) => price.id);
|
||||
};
|
||||
11
packages/ee/server-only/stripe/get-invoices.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
|
||||
export type GetInvoicesOptions = {
|
||||
customerId: string;
|
||||
};
|
||||
|
||||
export const getInvoices = async ({ customerId }: GetInvoicesOptions) => {
|
||||
return await stripe.invoices.list({
|
||||
customer: customerId,
|
||||
});
|
||||
};
|
||||
@ -4,7 +4,7 @@ import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
|
||||
export type GetPortalSessionOptions = {
|
||||
customerId: string;
|
||||
returnUrl: string;
|
||||
returnUrl?: string;
|
||||
};
|
||||
|
||||
export const getPortalSession = async ({ customerId, returnUrl }: GetPortalSessionOptions) => {
|
||||
|
||||
@ -9,12 +9,12 @@ export type PriceIntervals = Record<Stripe.Price.Recurring.Interval, PriceWithPr
|
||||
|
||||
export type GetPricesByIntervalOptions = {
|
||||
/**
|
||||
* Filter products by their meta 'type' attribute.
|
||||
* Filter products by their meta 'plan' attribute.
|
||||
*/
|
||||
type?: 'individual';
|
||||
plan?: 'community';
|
||||
};
|
||||
|
||||
export const getPricesByInterval = async ({ type }: GetPricesByIntervalOptions = {}) => {
|
||||
export const getPricesByInterval = async ({ plan }: GetPricesByIntervalOptions = {}) => {
|
||||
let { data: prices } = await stripe.prices.search({
|
||||
query: `active:'true' type:'recurring'`,
|
||||
expand: ['data.product'],
|
||||
@ -26,7 +26,7 @@ export const getPricesByInterval = async ({ type }: GetPricesByIntervalOptions =
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const product = price.product as Stripe.Product;
|
||||
|
||||
const filter = !type || product.metadata?.type === type;
|
||||
const filter = !plan || product.metadata?.plan === plan;
|
||||
|
||||
// Filter out prices for products that are not active.
|
||||
return product.active && filter;
|
||||
|
||||
18
packages/ee/server-only/stripe/get-prices-by-plan.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import type { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
|
||||
type PlanType = (typeof STRIPE_PLAN_TYPE)[keyof typeof STRIPE_PLAN_TYPE];
|
||||
|
||||
export const getPricesByPlan = async (plan: PlanType | PlanType[]) => {
|
||||
const planTypes = typeof plan === 'string' ? [plan] : plan;
|
||||
|
||||
const query = planTypes.map((planType) => `metadata['plan']:'${planType}'`).join(' OR ');
|
||||
|
||||
const { data: prices } = await stripe.prices.search({
|
||||
query,
|
||||
expand: ['data.product'],
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
return prices.filter((price) => price.type === 'recurring');
|
||||
};
|
||||
@ -1,11 +0,0 @@
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
|
||||
export const getPricesByType = async (type: 'individual') => {
|
||||
const { data: prices } = await stripe.prices.search({
|
||||
query: `metadata['type']:'${type}' type:'recurring'`,
|
||||
expand: ['data.product'],
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
return prices;
|
||||
};
|
||||
@ -0,0 +1,10 @@
|
||||
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
||||
|
||||
import { getPricesByPlan } from './get-prices-by-plan';
|
||||
|
||||
/**
|
||||
* Returns the prices of items that count as the account's primary plan.
|
||||
*/
|
||||
export const getPrimaryAccountPlanPrices = async () => {
|
||||
return await getPricesByPlan([STRIPE_PLAN_TYPE.COMMUNITY, STRIPE_PLAN_TYPE.ENTERPRISE]);
|
||||
};
|
||||
43
packages/ee/server-only/stripe/get-team-prices.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
|
||||
import { getPricesByPlan } from './get-prices-by-plan';
|
||||
|
||||
export const getTeamPrices = async () => {
|
||||
const prices = (await getPricesByPlan(STRIPE_PLAN_TYPE.TEAM)).filter((price) => price.active);
|
||||
|
||||
const monthlyPrice = prices.find((price) => price.recurring?.interval === 'month');
|
||||
const yearlyPrice = prices.find((price) => price.recurring?.interval === 'year');
|
||||
const priceIds = prices.map((price) => price.id);
|
||||
|
||||
if (!monthlyPrice || !yearlyPrice) {
|
||||
throw new AppError('INVALID_CONFIG', 'Missing monthly or yearly price');
|
||||
}
|
||||
|
||||
return {
|
||||
monthly: {
|
||||
friendlyInterval: 'Monthly',
|
||||
interval: 'monthly',
|
||||
...extractPriceData(monthlyPrice),
|
||||
},
|
||||
yearly: {
|
||||
friendlyInterval: 'Yearly',
|
||||
interval: 'yearly',
|
||||
...extractPriceData(yearlyPrice),
|
||||
},
|
||||
priceIds,
|
||||
} as const;
|
||||
};
|
||||
|
||||
const extractPriceData = (price: Stripe.Price) => {
|
||||
const product =
|
||||
typeof price.product !== 'string' && !price.product.deleted ? price.product : null;
|
||||
|
||||
return {
|
||||
priceId: price.id,
|
||||
description: product?.description ?? '',
|
||||
features: product?.features ?? [],
|
||||
};
|
||||
};
|
||||
17
packages/ee/server-only/stripe/get-team-related-prices.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
||||
|
||||
import { getPricesByPlan } from './get-prices-by-plan';
|
||||
|
||||
/**
|
||||
* Returns the Stripe prices of items that affect the amount of teams a user can create.
|
||||
*/
|
||||
export const getTeamRelatedPrices = async () => {
|
||||
return await getPricesByPlan([STRIPE_PLAN_TYPE.COMMUNITY, STRIPE_PLAN_TYPE.ENTERPRISE]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the Stripe price IDs of items that affect the amount of teams a user can create.
|
||||
*/
|
||||
export const getTeamRelatedPriceIds = async () => {
|
||||
return await getTeamRelatedPrices().then((prices) => prices.map((price) => price.id));
|
||||
};
|
||||
126
packages/ee/server-only/stripe/transfer-team-subscription.ts
Normal file
@ -0,0 +1,126 @@
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { subscriptionsContainsActivePlan } from '@documenso/lib/utils/billing';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { type Subscription, type Team, type User } from '@documenso/prisma/client';
|
||||
|
||||
import { deleteCustomerPaymentMethods } from './delete-customer-payment-methods';
|
||||
import { getTeamPrices } from './get-team-prices';
|
||||
import { getTeamRelatedPriceIds } from './get-team-related-prices';
|
||||
|
||||
type TransferStripeSubscriptionOptions = {
|
||||
/**
|
||||
* The user to transfer the subscription to.
|
||||
*/
|
||||
user: User & { Subscription: Subscription[] };
|
||||
|
||||
/**
|
||||
* The team the subscription is associated with.
|
||||
*/
|
||||
team: Team & { subscription?: Subscription | null };
|
||||
|
||||
/**
|
||||
* Whether to clear any current payment methods attached to the team.
|
||||
*/
|
||||
clearPaymentMethods: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Transfer the Stripe Team seats subscription from one user to another.
|
||||
*
|
||||
* 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 early adopter plan).
|
||||
*/
|
||||
export const transferTeamSubscription = async ({
|
||||
user,
|
||||
team,
|
||||
clearPaymentMethods,
|
||||
}: TransferStripeSubscriptionOptions) => {
|
||||
const teamCustomerId = team.customerId;
|
||||
|
||||
if (!teamCustomerId) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, 'Missing customer ID.');
|
||||
}
|
||||
|
||||
const [teamRelatedPlanPriceIds, teamSeatPrices] = await Promise.all([
|
||||
getTeamRelatedPriceIds(),
|
||||
getTeamPrices(),
|
||||
]);
|
||||
|
||||
const teamSubscriptionRequired = !subscriptionsContainsActivePlan(
|
||||
user.Subscription,
|
||||
teamRelatedPlanPriceIds,
|
||||
);
|
||||
|
||||
let teamSubscription: Stripe.Subscription | null = null;
|
||||
|
||||
if (team.subscription) {
|
||||
teamSubscription = await stripe.subscriptions.retrieve(team.subscription.planId);
|
||||
|
||||
if (!teamSubscription) {
|
||||
throw new Error('Could not find the current subscription.');
|
||||
}
|
||||
|
||||
if (clearPaymentMethods) {
|
||||
await deleteCustomerPaymentMethods({ customerId: teamCustomerId });
|
||||
}
|
||||
}
|
||||
|
||||
await stripe.customers.update(teamCustomerId, {
|
||||
name: user.name ?? team.name,
|
||||
email: user.email,
|
||||
});
|
||||
|
||||
// If team subscription is required and the team does not have a subscription, create one.
|
||||
if (teamSubscriptionRequired && !teamSubscription) {
|
||||
const numberOfSeats = await prisma.teamMember.count({
|
||||
where: {
|
||||
teamId: team.id,
|
||||
},
|
||||
});
|
||||
|
||||
const teamSeatPriceId = teamSeatPrices.monthly.priceId;
|
||||
|
||||
teamSubscription = await stripe.subscriptions.create({
|
||||
customer: teamCustomerId,
|
||||
items: [
|
||||
{
|
||||
price: teamSeatPriceId,
|
||||
quantity: numberOfSeats,
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
teamId: team.id.toString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// If no team subscription is required, cancel the current team subscription if it exists.
|
||||
if (!teamSubscriptionRequired && teamSubscription) {
|
||||
try {
|
||||
// Set the quantity to 0 so we can refund/charge the old Stripe customer the prorated amount.
|
||||
await stripe.subscriptions.update(teamSubscription.id, {
|
||||
items: teamSubscription.items.data.map((item) => ({
|
||||
id: item.id,
|
||||
quantity: 0,
|
||||
})),
|
||||
});
|
||||
|
||||
await stripe.subscriptions.cancel(teamSubscription.id, {
|
||||
invoice_now: true,
|
||||
prorate: false,
|
||||
});
|
||||
} catch (e) {
|
||||
// Do not error out since we can't easily undo the transfer.
|
||||
// Todo: Teams - Alert us.
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return teamSubscription;
|
||||
};
|
||||
18
packages/ee/server-only/stripe/update-customer.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
|
||||
type UpdateCustomerOptions = {
|
||||
customerId: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
};
|
||||
|
||||
export const updateCustomer = async ({ customerId, name, email }: UpdateCustomerOptions) => {
|
||||
if (!name && !email) {
|
||||
return;
|
||||
}
|
||||
|
||||
return await stripe.customers.update(customerId, {
|
||||
name,
|
||||
email,
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,44 @@
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
|
||||
export type UpdateSubscriptionItemQuantityOptions = {
|
||||
subscriptionId: string;
|
||||
quantity: number;
|
||||
priceId: string;
|
||||
};
|
||||
|
||||
export const updateSubscriptionItemQuantity = async ({
|
||||
subscriptionId,
|
||||
quantity,
|
||||
priceId,
|
||||
}: UpdateSubscriptionItemQuantityOptions) => {
|
||||
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
||||
|
||||
const items = subscription.items.data.filter((item) => item.price.id === priceId);
|
||||
|
||||
if (items.length !== 1) {
|
||||
throw new Error('Subscription does not contain required item');
|
||||
}
|
||||
|
||||
const hasYearlyItem = items.find((item) => item.price.recurring?.interval === 'year');
|
||||
const oldQuantity = items[0].quantity;
|
||||
|
||||
if (oldQuantity === quantity) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subscriptionUpdatePayload: Stripe.SubscriptionUpdateParams = {
|
||||
items: items.map((item) => ({
|
||||
id: item.id,
|
||||
quantity,
|
||||
})),
|
||||
};
|
||||
|
||||
// Only invoice immediately when changing the quantity of yearly item.
|
||||
if (hasYearlyItem) {
|
||||
subscriptionUpdatePayload.proration_behavior = 'always_invoice';
|
||||
}
|
||||
|
||||
await stripe.subscriptions.update(subscriptionId, subscriptionUpdatePayload);
|
||||
};
|
||||
@ -3,8 +3,10 @@ import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { buffer } from 'micro';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
||||
import type { Stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { createTeamFromPendingTeam } from '@documenso/lib/server-only/team/create-team';
|
||||
import { getFlag } from '@documenso/lib/universal/get-feature-flag';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
@ -84,14 +86,9 @@ export const stripeWebhookHandler = async (
|
||||
},
|
||||
});
|
||||
|
||||
if (!result?.id) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: 'User not found',
|
||||
});
|
||||
if (result?.id) {
|
||||
userId = result.id;
|
||||
}
|
||||
|
||||
userId = result.id;
|
||||
}
|
||||
|
||||
const subscriptionId =
|
||||
@ -99,7 +96,7 @@ export const stripeWebhookHandler = async (
|
||||
? session.subscription
|
||||
: session.subscription?.id;
|
||||
|
||||
if (!subscriptionId || Number.isNaN(userId)) {
|
||||
if (!subscriptionId) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: 'Invalid session',
|
||||
@ -108,6 +105,24 @@ export const stripeWebhookHandler = async (
|
||||
|
||||
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
||||
|
||||
// Handle team creation after seat checkout.
|
||||
if (subscription.items.data[0].price.metadata.plan === STRIPE_PLAN_TYPE.TEAM) {
|
||||
await handleTeamSeatCheckout({ subscription });
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: 'Webhook received',
|
||||
});
|
||||
}
|
||||
|
||||
// Validate user ID.
|
||||
if (!userId || Number.isNaN(userId)) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: 'Invalid session or missing user ID',
|
||||
});
|
||||
}
|
||||
|
||||
await onSubscriptionUpdated({ userId, subscription });
|
||||
|
||||
return res.status(200).json({
|
||||
@ -124,6 +139,28 @@ export const stripeWebhookHandler = async (
|
||||
? subscription.customer
|
||||
: subscription.customer.id;
|
||||
|
||||
if (subscription.items.data[0].price.metadata.plan === STRIPE_PLAN_TYPE.TEAM) {
|
||||
const team = await prisma.team.findFirst({
|
||||
where: {
|
||||
customerId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: 'No team associated with subscription found',
|
||||
});
|
||||
}
|
||||
|
||||
await onSubscriptionUpdated({ teamId: team.id, subscription });
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: 'Webhook received',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await prisma.user.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
@ -182,6 +219,28 @@ export const stripeWebhookHandler = async (
|
||||
});
|
||||
}
|
||||
|
||||
if (subscription.items.data[0].price.metadata.plan === STRIPE_PLAN_TYPE.TEAM) {
|
||||
const team = await prisma.team.findFirst({
|
||||
where: {
|
||||
customerId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: 'No team associated with subscription found',
|
||||
});
|
||||
}
|
||||
|
||||
await onSubscriptionUpdated({ teamId: team.id, subscription });
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: 'Webhook received',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await prisma.user.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
@ -233,6 +292,28 @@ export const stripeWebhookHandler = async (
|
||||
});
|
||||
}
|
||||
|
||||
if (subscription.items.data[0].price.metadata.plan === STRIPE_PLAN_TYPE.TEAM) {
|
||||
const team = await prisma.team.findFirst({
|
||||
where: {
|
||||
customerId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: 'No team associated with subscription found',
|
||||
});
|
||||
}
|
||||
|
||||
await onSubscriptionUpdated({ teamId: team.id, subscription });
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: 'Webhook received',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await prisma.user.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
@ -282,3 +363,21 @@ export const stripeWebhookHandler = async (
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export type HandleTeamSeatCheckoutOptions = {
|
||||
subscription: Stripe.Subscription;
|
||||
};
|
||||
|
||||
const handleTeamSeatCheckout = async ({ subscription }: HandleTeamSeatCheckoutOptions) => {
|
||||
if (subscription.metadata?.pendingTeamId === undefined) {
|
||||
throw new Error('Missing pending team ID');
|
||||
}
|
||||
|
||||
const pendingTeamId = Number(subscription.metadata.pendingTeamId);
|
||||
|
||||
if (Number.isNaN(pendingTeamId)) {
|
||||
throw new Error('Invalid pending team ID');
|
||||
}
|
||||
|
||||
return await createTeamFromPendingTeam({ pendingTeamId, subscription }).then((team) => team.id);
|
||||
};
|
||||
|
||||
@ -2,23 +2,40 @@ import { match } from 'ts-pattern';
|
||||
|
||||
import type { Stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { Prisma } from '@documenso/prisma/client';
|
||||
import { SubscriptionStatus } from '@documenso/prisma/client';
|
||||
|
||||
export type OnSubscriptionUpdatedOptions = {
|
||||
userId: number;
|
||||
userId?: number;
|
||||
teamId?: number;
|
||||
subscription: Stripe.Subscription;
|
||||
};
|
||||
|
||||
export const onSubscriptionUpdated = async ({
|
||||
userId,
|
||||
teamId,
|
||||
subscription,
|
||||
}: OnSubscriptionUpdatedOptions) => {
|
||||
await prisma.subscription.upsert(
|
||||
mapStripeSubscriptionToPrismaUpsertAction(subscription, userId, teamId),
|
||||
);
|
||||
};
|
||||
|
||||
export const mapStripeSubscriptionToPrismaUpsertAction = (
|
||||
subscription: Stripe.Subscription,
|
||||
userId?: number,
|
||||
teamId?: number,
|
||||
): Prisma.SubscriptionUpsertArgs => {
|
||||
if ((!userId && !teamId) || (userId && teamId)) {
|
||||
throw new Error('Either userId or teamId must be provided.');
|
||||
}
|
||||
|
||||
const status = match(subscription.status)
|
||||
.with('active', () => SubscriptionStatus.ACTIVE)
|
||||
.with('past_due', () => SubscriptionStatus.PAST_DUE)
|
||||
.otherwise(() => SubscriptionStatus.INACTIVE);
|
||||
|
||||
await prisma.subscription.upsert({
|
||||
return {
|
||||
where: {
|
||||
planId: subscription.id,
|
||||
},
|
||||
@ -27,7 +44,8 @@ export const onSubscriptionUpdated = async ({
|
||||
planId: subscription.id,
|
||||
priceId: subscription.items.data[0].price.id,
|
||||
periodEnd: new Date(subscription.current_period_end * 1000),
|
||||
userId,
|
||||
userId: userId ?? null,
|
||||
teamId: teamId ?? null,
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
},
|
||||
update: {
|
||||
@ -37,5 +55,5 @@ export const onSubscriptionUpdated = async ({
|
||||
periodEnd: new Date(subscription.current_period_end * 1000),
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
},
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
@ -35,14 +35,14 @@
|
||||
"@react-email/section": "0.0.10",
|
||||
"@react-email/tailwind": "0.0.9",
|
||||
"@react-email/text": "0.0.6",
|
||||
"nodemailer": "^6.9.3",
|
||||
"nodemailer": "^6.9.9",
|
||||
"react-email": "^1.9.5",
|
||||
"resend": "^2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@documenso/tailwind-config": "*",
|
||||
"@documenso/tsconfig": "*",
|
||||
"@types/nodemailer": "^6.4.8",
|
||||
"@types/nodemailer": "^6.4.14",
|
||||
"tsup": "^7.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
packages/email/static/add-user.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
packages/email/static/mail-open-alert.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
packages/email/static/mail-open.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
@ -1,3 +1,6 @@
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import type { RecipientRole } from '@documenso/prisma/client';
|
||||
|
||||
import { Button, Section, Text } from '../components';
|
||||
import { TemplateDocumentImage } from './template-document-image';
|
||||
|
||||
@ -7,6 +10,7 @@ export interface TemplateDocumentInviteProps {
|
||||
documentName: string;
|
||||
signDocumentLink: string;
|
||||
assetBaseUrl: string;
|
||||
role: RecipientRole;
|
||||
}
|
||||
|
||||
export const TemplateDocumentInvite = ({
|
||||
@ -14,19 +18,22 @@ export const TemplateDocumentInvite = ({
|
||||
documentName,
|
||||
signDocumentLink,
|
||||
assetBaseUrl,
|
||||
role,
|
||||
}: TemplateDocumentInviteProps) => {
|
||||
const { actionVerb, progressiveVerb } = RECIPIENT_ROLES_DESCRIPTION[role];
|
||||
|
||||
return (
|
||||
<>
|
||||
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
||||
|
||||
<Section>
|
||||
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
|
||||
{inviterName} has invited you to sign
|
||||
{inviterName} has invited you to {actionVerb.toLowerCase()}
|
||||
<br />"{documentName}"
|
||||
</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base text-slate-400">
|
||||
Continue by signing the document.
|
||||
Continue by {progressiveVerb.toLowerCase()} the document.
|
||||
</Text>
|
||||
|
||||
<Section className="mb-6 mt-8 text-center">
|
||||
@ -34,7 +41,7 @@ export const TemplateDocumentInvite = ({
|
||||
className="bg-documenso-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
|
||||
href={signDocumentLink}
|
||||
>
|
||||
Sign Document
|
||||
{actionVerb} Document
|
||||
</Button>
|
||||
</Section>
|
||||
</Section>
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { env } from 'next-runtime-env';
|
||||
|
||||
import { Button, Column, Img, Link, Section, Text } from '../components';
|
||||
import { TemplateDocumentImage } from './template-document-image';
|
||||
|
||||
@ -10,7 +12,9 @@ export const TemplateDocumentSelfSigned = ({
|
||||
documentName,
|
||||
assetBaseUrl,
|
||||
}: TemplateDocumentSelfSignedProps) => {
|
||||
const signUpUrl = `${process.env.NEXT_PUBLIC_WEBAPP_URL ?? 'http://localhost:3000'}/signup`;
|
||||
const NEXT_PUBLIC_WEBAPP_URL = env('NEXT_PUBLIC_WEBAPP_URL');
|
||||
|
||||
const signUpUrl = `${NEXT_PUBLIC_WEBAPP_URL ?? 'http://localhost:3000'}/signup`;
|
||||
|
||||
const getAssetUrl = (path: string) => {
|
||||
return new URL(path, assetBaseUrl).toString();
|
||||
|
||||
17
packages/email/template-components/template-image.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import { Img } from '../components';
|
||||
|
||||
export interface TemplateImageProps {
|
||||
assetBaseUrl: string;
|
||||
className?: string;
|
||||
staticAsset: string;
|
||||
}
|
||||
|
||||
export const TemplateImage = ({ assetBaseUrl, className, staticAsset }: TemplateImageProps) => {
|
||||
const getAssetUrl = (path: string) => {
|
||||
return new URL(path, assetBaseUrl).toString();
|
||||
};
|
||||
|
||||
return <Img className={className} src={getAssetUrl(`/static/${staticAsset}`)} />;
|
||||
};
|
||||
|
||||
export default TemplateImage;
|
||||
@ -1,3 +1,5 @@
|
||||
import { env } from 'next-runtime-env';
|
||||
|
||||
import { Button, Section, Text } from '../components';
|
||||
import { TemplateDocumentImage } from './template-document-image';
|
||||
|
||||
@ -8,6 +10,8 @@ export interface TemplateResetPasswordProps {
|
||||
}
|
||||
|
||||
export const TemplateResetPassword = ({ assetBaseUrl }: TemplateResetPasswordProps) => {
|
||||
const NEXT_PUBLIC_WEBAPP_URL = env('NEXT_PUBLIC_WEBAPP_URL');
|
||||
|
||||
return (
|
||||
<>
|
||||
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
||||
@ -24,7 +28,7 @@ export const TemplateResetPassword = ({ assetBaseUrl }: TemplateResetPasswordPro
|
||||
<Section className="mb-6 mt-8 text-center">
|
||||
<Button
|
||||
className="bg-documenso-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
|
||||
href={`${process.env.NEXT_PUBLIC_WEBAPP_URL ?? 'http://localhost:3000'}/signin`}
|
||||
href={`${NEXT_PUBLIC_WEBAPP_URL ?? 'http://localhost:3000'}/signin`}
|
||||
>
|
||||
Sign In
|
||||
</Button>
|
||||
|
||||
@ -7,7 +7,7 @@ import { TemplateFooter } from '../template-components/template-footer';
|
||||
|
||||
export const ConfirmEmailTemplate = ({
|
||||
confirmationLink,
|
||||
assetBaseUrl,
|
||||
assetBaseUrl = 'http://localhost:3002',
|
||||
}: TemplateConfirmationEmailProps) => {
|
||||
const previewText = `Please confirm your email address`;
|
||||
|
||||
@ -55,3 +55,5 @@ export const ConfirmEmailTemplate = ({
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfirmEmailTemplate;
|
||||
|
||||
127
packages/email/templates/confirm-team-email.tsx
Normal file
@ -0,0 +1,127 @@
|
||||
import { formatTeamUrl } from '@documenso/lib/utils/teams';
|
||||
import config from '@documenso/tailwind-config';
|
||||
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Hr,
|
||||
Html,
|
||||
Link,
|
||||
Preview,
|
||||
Section,
|
||||
Tailwind,
|
||||
Text,
|
||||
} from '../components';
|
||||
import { TemplateFooter } from '../template-components/template-footer';
|
||||
import TemplateImage from '../template-components/template-image';
|
||||
|
||||
export type ConfirmTeamEmailProps = {
|
||||
assetBaseUrl: string;
|
||||
baseUrl: string;
|
||||
teamName: string;
|
||||
teamUrl: string;
|
||||
token: string;
|
||||
};
|
||||
|
||||
export const ConfirmTeamEmailTemplate = ({
|
||||
assetBaseUrl = 'http://localhost:3002',
|
||||
baseUrl = 'https://documenso.com',
|
||||
teamName = 'Team Name',
|
||||
teamUrl = 'demo',
|
||||
token = '',
|
||||
}: ConfirmTeamEmailProps) => {
|
||||
const previewText = `Accept team email request for ${teamName} on Documenso`;
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: config.theme.extend.colors,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Section className="bg-white">
|
||||
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 px-2 pt-2 backdrop-blur-sm">
|
||||
<TemplateImage
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
className="mb-4 h-6 p-2"
|
||||
staticAsset="logo.png"
|
||||
/>
|
||||
|
||||
<Section>
|
||||
<TemplateImage
|
||||
className="mx-auto"
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
staticAsset="mail-open.png"
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section className="p-2 text-slate-500">
|
||||
<Text className="text-center text-lg font-medium text-black">
|
||||
Verify your team email address
|
||||
</Text>
|
||||
|
||||
<Text className="text-center text-base">
|
||||
<span className="font-bold">{teamName}</span> has requested to use your email
|
||||
address for their team on Documenso.
|
||||
</Text>
|
||||
|
||||
<div className="mx-auto mt-6 w-fit rounded-lg bg-gray-50 px-4 py-2 text-base font-medium text-slate-600">
|
||||
{formatTeamUrl(teamUrl, baseUrl)}
|
||||
</div>
|
||||
|
||||
<Section className="mt-6">
|
||||
<Text className="my-0 text-sm">
|
||||
By accepting this request, you will be granting <strong>{teamName}</strong>{' '}
|
||||
access to:
|
||||
</Text>
|
||||
|
||||
<ul className="mb-0 mt-2">
|
||||
<li className="text-sm">
|
||||
View all documents sent to and from this email address
|
||||
</li>
|
||||
<li className="mt-1 text-sm">
|
||||
Allow document recipients to reply directly to this email address
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<Text className="mt-2 text-sm">
|
||||
You can revoke access at any time in your team settings on Documenso{' '}
|
||||
<Link href={`${baseUrl}/settings/teams`}>here.</Link>
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Section className="mb-6 mt-8 text-center">
|
||||
<Button
|
||||
className="bg-documenso-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
|
||||
href={`${baseUrl}/team/verify/email/${token}`}
|
||||
>
|
||||
Accept
|
||||
</Button>
|
||||
</Section>
|
||||
</Section>
|
||||
|
||||
<Text className="text-center text-xs text-slate-500">Link expires in 1 hour.</Text>
|
||||
</Container>
|
||||
|
||||
<Hr className="mx-auto mt-12 max-w-xl" />
|
||||
|
||||
<Container className="mx-auto max-w-xl">
|
||||
<TemplateFooter isDocument={false} />
|
||||
</Container>
|
||||
</Section>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfirmTeamEmailTemplate;
|
||||
@ -1,3 +1,5 @@
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import type { RecipientRole } from '@documenso/prisma/client';
|
||||
import config from '@documenso/tailwind-config';
|
||||
|
||||
import {
|
||||
@ -19,6 +21,7 @@ import { TemplateFooter } from '../template-components/template-footer';
|
||||
|
||||
export type DocumentInviteEmailTemplateProps = Partial<TemplateDocumentInviteProps> & {
|
||||
customBody?: string;
|
||||
role: RecipientRole;
|
||||
};
|
||||
|
||||
export const DocumentInviteEmailTemplate = ({
|
||||
@ -28,8 +31,11 @@ export const DocumentInviteEmailTemplate = ({
|
||||
signDocumentLink = 'https://documenso.com',
|
||||
assetBaseUrl = 'http://localhost:3002',
|
||||
customBody,
|
||||
role,
|
||||
}: DocumentInviteEmailTemplateProps) => {
|
||||
const previewText = `${inviterName} has invited you to sign ${documentName}`;
|
||||
const action = RECIPIENT_ROLES_DESCRIPTION[role].actionVerb.toLowerCase();
|
||||
|
||||
const previewText = `${inviterName} has invited you to ${action} ${documentName}`;
|
||||
|
||||
const getAssetUrl = (path: string) => {
|
||||
return new URL(path, assetBaseUrl).toString();
|
||||
@ -64,6 +70,7 @@ export const DocumentInviteEmailTemplate = ({
|
||||
documentName={documentName}
|
||||
signDocumentLink={signDocumentLink}
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
role={role}
|
||||
/>
|
||||
</Section>
|
||||
</Container>
|
||||
@ -81,7 +88,7 @@ export const DocumentInviteEmailTemplate = ({
|
||||
{customBody ? (
|
||||
<pre className="font-sans text-base text-slate-400">{customBody}</pre>
|
||||
) : (
|
||||
`${inviterName} has invited you to sign the document "${documentName}".`
|
||||
`${inviterName} has invited you to ${action} the document "${documentName}".`
|
||||
)}
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
83
packages/email/templates/team-email-removed.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
import { formatTeamUrl } from '@documenso/lib/utils/teams';
|
||||
import config from '@documenso/tailwind-config';
|
||||
|
||||
import { Body, Container, Head, Hr, Html, Preview, Section, Tailwind, Text } from '../components';
|
||||
import { TemplateFooter } from '../template-components/template-footer';
|
||||
import TemplateImage from '../template-components/template-image';
|
||||
|
||||
export type TeamEmailRemovedTemplateProps = {
|
||||
assetBaseUrl: string;
|
||||
baseUrl: string;
|
||||
teamEmail: string;
|
||||
teamName: string;
|
||||
teamUrl: string;
|
||||
};
|
||||
|
||||
export const TeamEmailRemovedTemplate = ({
|
||||
assetBaseUrl = 'http://localhost:3002',
|
||||
baseUrl = 'https://documenso.com',
|
||||
teamEmail = 'example@documenso.com',
|
||||
teamName = 'Team Name',
|
||||
teamUrl = 'demo',
|
||||
}: TeamEmailRemovedTemplateProps) => {
|
||||
const previewText = `Team email removed for ${teamName} on Documenso`;
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: config.theme.extend.colors,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Section className="bg-white text-slate-500">
|
||||
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 px-2 pt-2 backdrop-blur-sm">
|
||||
<TemplateImage
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
className="mb-4 h-6 p-2"
|
||||
staticAsset="logo.png"
|
||||
/>
|
||||
|
||||
<Section>
|
||||
<TemplateImage
|
||||
className="mx-auto"
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
staticAsset="mail-open-alert.png"
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section className="p-2 text-slate-500">
|
||||
<Text className="text-center text-lg font-medium text-black">
|
||||
Team email removed
|
||||
</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base">
|
||||
The team email <span className="font-bold">{teamEmail}</span> has been removed
|
||||
from the following team
|
||||
</Text>
|
||||
|
||||
<div className="mx-auto mb-6 mt-2 w-fit rounded-lg bg-gray-50 px-4 py-2 text-base font-medium text-slate-600">
|
||||
{formatTeamUrl(teamUrl, baseUrl)}
|
||||
</div>
|
||||
</Section>
|
||||
</Container>
|
||||
|
||||
<Hr className="mx-auto mt-12 max-w-xl" />
|
||||
|
||||
<Container className="mx-auto max-w-xl">
|
||||
<TemplateFooter isDocument={false} />
|
||||
</Container>
|
||||
</Section>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default TeamEmailRemovedTemplate;
|
||||
108
packages/email/templates/team-invite.tsx
Normal file
@ -0,0 +1,108 @@
|
||||
import { formatTeamUrl } from '@documenso/lib/utils/teams';
|
||||
import config from '@documenso/tailwind-config';
|
||||
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Hr,
|
||||
Html,
|
||||
Preview,
|
||||
Section,
|
||||
Tailwind,
|
||||
Text,
|
||||
} from '../components';
|
||||
import { TemplateFooter } from '../template-components/template-footer';
|
||||
import TemplateImage from '../template-components/template-image';
|
||||
|
||||
export type TeamInviteEmailProps = {
|
||||
assetBaseUrl: string;
|
||||
baseUrl: string;
|
||||
senderName: string;
|
||||
teamName: string;
|
||||
teamUrl: string;
|
||||
token: string;
|
||||
};
|
||||
|
||||
export const TeamInviteEmailTemplate = ({
|
||||
assetBaseUrl = 'http://localhost:3002',
|
||||
baseUrl = 'https://documenso.com',
|
||||
senderName = 'John Doe',
|
||||
teamName = 'Team Name',
|
||||
teamUrl = 'demo',
|
||||
token = '',
|
||||
}: TeamInviteEmailProps) => {
|
||||
const previewText = `Accept invitation to join a team on Documenso`;
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: config.theme.extend.colors,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Section className="bg-white text-slate-500">
|
||||
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-2 backdrop-blur-sm">
|
||||
<TemplateImage
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
className="mb-4 h-6 p-2"
|
||||
staticAsset="logo.png"
|
||||
/>
|
||||
|
||||
<Section>
|
||||
<TemplateImage
|
||||
className="mx-auto"
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
staticAsset="add-user.png"
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section className="p-2 text-slate-500">
|
||||
<Text className="text-center text-lg font-medium text-black">
|
||||
Join {teamName} on Documenso
|
||||
</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base">
|
||||
You have been invited to join the following team
|
||||
</Text>
|
||||
|
||||
<div className="mx-auto my-2 w-fit rounded-lg bg-gray-50 px-4 py-2 text-base font-medium text-slate-600">
|
||||
{formatTeamUrl(teamUrl, baseUrl)}
|
||||
</div>
|
||||
|
||||
<Text className="my-1 text-center text-base">
|
||||
by <span className="text-slate-900">{senderName}</span>
|
||||
</Text>
|
||||
|
||||
<Section className="mb-6 mt-6 text-center">
|
||||
<Button
|
||||
className="bg-documenso-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
|
||||
href={`${baseUrl}/team/invite/${token}`}
|
||||
>
|
||||
Accept
|
||||
</Button>
|
||||
</Section>
|
||||
</Section>
|
||||
</Container>
|
||||
|
||||
<Hr className="mx-auto mt-12 max-w-xl" />
|
||||
|
||||
<Container className="mx-auto max-w-xl">
|
||||
<TemplateFooter isDocument={false} />
|
||||
</Container>
|
||||
</Section>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default TeamInviteEmailTemplate;
|
||||
112
packages/email/templates/team-transfer-request.tsx
Normal file
@ -0,0 +1,112 @@
|
||||
import { formatTeamUrl } from '@documenso/lib/utils/teams';
|
||||
import config from '@documenso/tailwind-config';
|
||||
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Hr,
|
||||
Html,
|
||||
Preview,
|
||||
Section,
|
||||
Tailwind,
|
||||
Text,
|
||||
} from '../components';
|
||||
import { TemplateFooter } from '../template-components/template-footer';
|
||||
import TemplateImage from '../template-components/template-image';
|
||||
|
||||
export type TeamTransferRequestTemplateProps = {
|
||||
assetBaseUrl: string;
|
||||
baseUrl: string;
|
||||
senderName: string;
|
||||
teamName: string;
|
||||
teamUrl: string;
|
||||
token: string;
|
||||
};
|
||||
|
||||
export const TeamTransferRequestTemplate = ({
|
||||
assetBaseUrl = 'http://localhost:3002',
|
||||
baseUrl = 'https://documenso.com',
|
||||
senderName = 'John Doe',
|
||||
teamName = 'Team Name',
|
||||
teamUrl = 'demo',
|
||||
token = '',
|
||||
}: TeamTransferRequestTemplateProps) => {
|
||||
const previewText = 'Accept team transfer request on Documenso';
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: config.theme.extend.colors,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Section className="bg-white text-slate-500">
|
||||
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 px-2 pt-2 backdrop-blur-sm">
|
||||
<TemplateImage
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
className="mb-4 h-6 p-2"
|
||||
staticAsset="logo.png"
|
||||
/>
|
||||
|
||||
<Section>
|
||||
<TemplateImage
|
||||
className="mx-auto"
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
staticAsset="add-user.png"
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section className="p-2 text-slate-500">
|
||||
<Text className="text-center text-lg font-medium text-black">
|
||||
{teamName} ownership transfer request
|
||||
</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base">
|
||||
<span className="font-bold">{senderName}</span> has requested that you take
|
||||
ownership of the following team
|
||||
</Text>
|
||||
|
||||
<div className="mx-auto my-2 w-fit rounded-lg bg-gray-50 px-4 py-2 text-base font-medium text-slate-600">
|
||||
{formatTeamUrl(teamUrl, baseUrl)}
|
||||
</div>
|
||||
|
||||
<Text className="text-center text-sm">
|
||||
By accepting this request, you will take responsibility for any billing items
|
||||
associated with this team.
|
||||
</Text>
|
||||
|
||||
<Section className="mb-6 mt-6 text-center">
|
||||
<Button
|
||||
className="bg-documenso-500 ml-2 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
|
||||
href={`${baseUrl}/team/verify/transfer/${token}`}
|
||||
>
|
||||
Accept
|
||||
</Button>
|
||||
</Section>
|
||||
</Section>
|
||||
|
||||
<Text className="text-center text-xs">Link expires in 1 hour.</Text>
|
||||
</Container>
|
||||
|
||||
<Hr className="mx-auto mt-12 max-w-xl" />
|
||||
|
||||
<Container className="mx-auto max-w-xl">
|
||||
<TemplateFooter isDocument={false} />
|
||||
</Container>
|
||||
</Section>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default TeamTransferRequestTemplate;
|
||||
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';
|
||||
|
||||
|
||||
13
packages/lib/client-only/hooks/use-effect-once.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import type { EffectCallback } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Dangerously runs an effect "once" by ignoring the depedencies of a given effect.
|
||||
*
|
||||
* DANGER: The effect will run twice in concurrent react and development environments.
|
||||
*/
|
||||
export const unsafe_useEffectOnce = (callback: EffectCallback) => {
|
||||
// Intentionally avoiding exhaustive deps and rule of hooks here
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps, react-hooks/rules-of-hooks
|
||||
return useEffect(callback, []);
|
||||
};
|
||||
@ -1,10 +1,10 @@
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
import { ReadStatus, SendStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
import { ReadStatus, RecipientRole, SendStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
|
||||
export const getRecipientType = (recipient: Recipient) => {
|
||||
if (
|
||||
recipient.sendStatus === SendStatus.SENT &&
|
||||
recipient.signingStatus === SigningStatus.SIGNED
|
||||
recipient.role === RecipientRole.CC ||
|
||||
(recipient.sendStatus === SendStatus.SENT && recipient.signingStatus === SigningStatus.SIGNED)
|
||||
) {
|
||||
return 'completed';
|
||||
}
|
||||
|
||||
@ -1,8 +1,19 @@
|
||||
import { env } from 'next-runtime-env';
|
||||
|
||||
export const APP_DOCUMENT_UPLOAD_SIZE_LIMIT =
|
||||
Number(process.env.NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT) || 50;
|
||||
|
||||
export const NEXT_PUBLIC_WEBAPP_URL = () => env('NEXT_PUBLIC_WEBAPP_URL');
|
||||
export const NEXT_PUBLIC_MARKETING_URL = () => env('NEXT_PUBLIC_MARKETING_URL');
|
||||
|
||||
export const IS_APP_MARKETING = process.env.NEXT_PUBLIC_PROJECT === 'marketing';
|
||||
export const IS_APP_WEB = process.env.NEXT_PUBLIC_PROJECT === 'web';
|
||||
export const IS_BILLING_ENABLED = () => env('NEXT_PUBLIC_FEATURE_BILLING_ENABLED') === 'true';
|
||||
|
||||
export const APP_FOLDER = IS_APP_MARKETING ? 'marketing' : 'web';
|
||||
export const APP_FOLDER = () => (IS_APP_MARKETING ? 'marketing' : 'web');
|
||||
|
||||
export const APP_BASE_URL = IS_APP_WEB
|
||||
? process.env.NEXT_PUBLIC_WEBAPP_URL
|
||||
: process.env.NEXT_PUBLIC_MARKETING_URL;
|
||||
export const APP_BASE_URL = () =>
|
||||
IS_APP_WEB ? NEXT_PUBLIC_WEBAPP_URL() : NEXT_PUBLIC_MARKETING_URL();
|
||||
|
||||
export const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000';
|
||||
export const MARKETING_BASE_URL = NEXT_PUBLIC_MARKETING_URL() ?? 'http://localhost:3001';
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { IdentityProvider } from '@documenso/prisma/client';
|
||||
import { IdentityProvider, UserSecurityAuditLogType } from '@documenso/prisma/client';
|
||||
|
||||
export const SALT_ROUNDS = 12;
|
||||
|
||||
@ -10,3 +10,16 @@ export const IDENTITY_PROVIDER_NAME: { [key in IdentityProvider]: string } = {
|
||||
export const IS_GOOGLE_SSO_ENABLED = Boolean(
|
||||
process.env.NEXT_PRIVATE_GOOGLE_CLIENT_ID && process.env.NEXT_PRIVATE_GOOGLE_CLIENT_SECRET,
|
||||
);
|
||||
|
||||
export const USER_SECURITY_AUDIT_LOG_MAP: { [key in UserSecurityAuditLogType]: string } = {
|
||||
[UserSecurityAuditLogType.ACCOUNT_SSO_LINK]: 'Linked account to SSO',
|
||||
[UserSecurityAuditLogType.ACCOUNT_PROFILE_UPDATE]: 'Profile updated',
|
||||
[UserSecurityAuditLogType.AUTH_2FA_DISABLE]: '2FA Disabled',
|
||||
[UserSecurityAuditLogType.AUTH_2FA_ENABLE]: '2FA Enabled',
|
||||
[UserSecurityAuditLogType.PASSWORD_RESET]: 'Password reset',
|
||||
[UserSecurityAuditLogType.PASSWORD_UPDATE]: 'Password updated',
|
||||
[UserSecurityAuditLogType.SIGN_OUT]: 'Signed Out',
|
||||
[UserSecurityAuditLogType.SIGN_IN]: 'Signed In',
|
||||
[UserSecurityAuditLogType.SIGN_IN_FAIL]: 'Sign in attempt failed',
|
||||
[UserSecurityAuditLogType.SIGN_IN_2FA_FAIL]: 'Sign in 2FA attempt failed',
|
||||
};
|
||||
|
||||
10
packages/lib/constants/billing.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export enum STRIPE_CUSTOMER_TYPE {
|
||||
INDIVIDUAL = 'individual',
|
||||
TEAM = 'team',
|
||||
}
|
||||
|
||||
export enum STRIPE_PLAN_TYPE {
|
||||
TEAM = 'team',
|
||||
COMMUNITY = 'community',
|
||||
ENTERPRISE = 'enterprise',
|
||||
}
|
||||
@ -1 +1,25 @@
|
||||
export const DOCUMENSO_ENCRYPTION_KEY = process.env.NEXT_PRIVATE_ENCRYPTION_KEY;
|
||||
|
||||
export const DOCUMENSO_ENCRYPTION_SECONDARY_KEY = process.env.NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY;
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
if (!DOCUMENSO_ENCRYPTION_KEY || !DOCUMENSO_ENCRYPTION_SECONDARY_KEY) {
|
||||
throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY or DOCUMENSO_ENCRYPTION_SECONDARY_KEY keys');
|
||||
}
|
||||
|
||||
if (DOCUMENSO_ENCRYPTION_KEY === DOCUMENSO_ENCRYPTION_SECONDARY_KEY) {
|
||||
throw new Error(
|
||||
'DOCUMENSO_ENCRYPTION_KEY and DOCUMENSO_ENCRYPTION_SECONDARY_KEY cannot be equal',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (DOCUMENSO_ENCRYPTION_KEY === 'CAFEBABE') {
|
||||
console.warn('*********************************************************************');
|
||||
console.warn('*');
|
||||
console.warn('*');
|
||||
console.warn('Please change the encryption key from the default value of "CAFEBABE"');
|
||||
console.warn('*');
|
||||
console.warn('*');
|
||||
console.warn('*********************************************************************');
|
||||
}
|
||||
|
||||
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>;
|
||||
@ -1,5 +1,10 @@
|
||||
import { env } from 'next-runtime-env';
|
||||
|
||||
import { APP_BASE_URL } from './app';
|
||||
|
||||
const NEXT_PUBLIC_FEATURE_BILLING_ENABLED = () => env('NEXT_PUBLIC_FEATURE_BILLING_ENABLED');
|
||||
const NEXT_PUBLIC_POSTHOG_KEY = () => env('NEXT_PUBLIC_POSTHOG_KEY');
|
||||
|
||||
/**
|
||||
* The flag name for global session recording feature flag.
|
||||
*/
|
||||
@ -16,16 +21,18 @@ export const FEATURE_FLAG_POLL_INTERVAL = 30000;
|
||||
* Does not take any person or group properties into account.
|
||||
*/
|
||||
export const LOCAL_FEATURE_FLAGS: Record<string, boolean> = {
|
||||
app_billing: process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true',
|
||||
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;
|
||||
|
||||
/**
|
||||
* Extract the PostHog configuration from the environment.
|
||||
*/
|
||||
export function extractPostHogConfig(): { key: string; host: string } | null {
|
||||
const postHogKey = process.env.NEXT_PUBLIC_POSTHOG_KEY;
|
||||
const postHogHost = `${APP_BASE_URL}/ingest`;
|
||||
const postHogKey = NEXT_PUBLIC_POSTHOG_KEY();
|
||||
const postHogHost = `${APP_BASE_URL()}/ingest`;
|
||||
|
||||
if (!postHogKey || !postHogHost) {
|
||||
return null;
|
||||
|
||||
@ -6,4 +6,4 @@ export const DEFAULT_HANDWRITING_FONT_SIZE = 50;
|
||||
export const MIN_STANDARD_FONT_SIZE = 8;
|
||||
export const MIN_HANDWRITING_FONT_SIZE = 20;
|
||||
|
||||
export const CAVEAT_FONT_PATH = `${APP_BASE_URL}/fonts/caveat.ttf`;
|
||||
export const CAVEAT_FONT_PATH = () => `${APP_BASE_URL()}/fonts/caveat.ttf`;
|
||||
|
||||
34
packages/lib/constants/recipient-roles.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { RecipientRole } from '@documenso/prisma/client';
|
||||
|
||||
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',
|
||||
},
|
||||
[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',
|
||||
[RecipientRole.VIEWER]: 'VIEW_REQUEST',
|
||||
[RecipientRole.APPROVER]: 'APPROVE_REQUEST',
|
||||
} as const;
|
||||
103
packages/lib/constants/teams.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import { TeamMemberRole } from '@documenso/prisma/client';
|
||||
|
||||
export const TEAM_URL_ROOT_REGEX = new RegExp('^/t/[^/]+$');
|
||||
export const TEAM_URL_REGEX = new RegExp('^/t/[^/]+');
|
||||
|
||||
export const TEAM_MEMBER_ROLE_MAP: Record<keyof typeof TeamMemberRole, string> = {
|
||||
ADMIN: 'Admin',
|
||||
MANAGER: 'Manager',
|
||||
MEMBER: 'Member',
|
||||
};
|
||||
|
||||
export const TEAM_MEMBER_ROLE_PERMISSIONS_MAP = {
|
||||
/**
|
||||
* Includes permissions to:
|
||||
* - Manage team members
|
||||
* - Manage team settings, changing name, url, etc.
|
||||
*/
|
||||
MANAGE_TEAM: [TeamMemberRole.ADMIN, TeamMemberRole.MANAGER],
|
||||
MANAGE_BILLING: [TeamMemberRole.ADMIN],
|
||||
DELETE_TEAM_TRANSFER_REQUEST: [TeamMemberRole.ADMIN],
|
||||
} satisfies Record<string, TeamMemberRole[]>;
|
||||
|
||||
/**
|
||||
* A hierarchy of team member roles to determine which role has higher permission than another.
|
||||
*/
|
||||
export const TEAM_MEMBER_ROLE_HIERARCHY = {
|
||||
[TeamMemberRole.ADMIN]: [TeamMemberRole.ADMIN, TeamMemberRole.MANAGER, TeamMemberRole.MEMBER],
|
||||
[TeamMemberRole.MANAGER]: [TeamMemberRole.MANAGER, TeamMemberRole.MEMBER],
|
||||
[TeamMemberRole.MEMBER]: [TeamMemberRole.MEMBER],
|
||||
} satisfies Record<TeamMemberRole, TeamMemberRole[]>;
|
||||
|
||||
export const PROTECTED_TEAM_URLS = [
|
||||
'403',
|
||||
'404',
|
||||
'500',
|
||||
'502',
|
||||
'503',
|
||||
'504',
|
||||
'about',
|
||||
'account',
|
||||
'admin',
|
||||
'administrator',
|
||||
'api',
|
||||
'app',
|
||||
'archive',
|
||||
'auth',
|
||||
'backup',
|
||||
'config',
|
||||
'configure',
|
||||
'contact',
|
||||
'contact-us',
|
||||
'copyright',
|
||||
'crime',
|
||||
'criminal',
|
||||
'dashboard',
|
||||
'docs',
|
||||
'documenso',
|
||||
'documentation',
|
||||
'document',
|
||||
'documents',
|
||||
'error',
|
||||
'exploit',
|
||||
'exploitation',
|
||||
'exploiter',
|
||||
'feedback',
|
||||
'finance',
|
||||
'forgot-password',
|
||||
'fraud',
|
||||
'fraudulent',
|
||||
'hack',
|
||||
'hacker',
|
||||
'harassment',
|
||||
'help',
|
||||
'helpdesk',
|
||||
'illegal',
|
||||
'internal',
|
||||
'legal',
|
||||
'login',
|
||||
'logout',
|
||||
'maintenance',
|
||||
'malware',
|
||||
'newsletter',
|
||||
'policy',
|
||||
'privacy',
|
||||
'profile',
|
||||
'public',
|
||||
'reset-password',
|
||||
'scam',
|
||||
'scammer',
|
||||
'settings',
|
||||
'setup',
|
||||
'sign',
|
||||
'signin',
|
||||
'signout',
|
||||
'signup',
|
||||
'spam',
|
||||
'support',
|
||||
'system',
|
||||
'team',
|
||||
'terms',
|
||||
'virus',
|
||||
'webhook',
|
||||
];
|
||||
@ -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 });
|
||||
|
||||
2
packages/lib/constants/url-regex.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export const URL_REGEX =
|
||||
/^(https?):\/\/(?:www\.)?[a-zA-Z0-9-]+\.[a-zA-Z0-9()]{2,}(?:\/[a-zA-Z0-9-._?&=/]*)?$/i;
|
||||
148
packages/lib/errors/app-error.ts
Normal file
@ -0,0 +1,148 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { TRPCClientError } from '@documenso/trpc/client';
|
||||
|
||||
/**
|
||||
* Generic application error codes.
|
||||
*/
|
||||
export enum AppErrorCode {
|
||||
'ALREADY_EXISTS' = 'AlreadyExists',
|
||||
'EXPIRED_CODE' = 'ExpiredCode',
|
||||
'INVALID_BODY' = 'InvalidBody',
|
||||
'INVALID_REQUEST' = 'InvalidRequest',
|
||||
'NOT_FOUND' = 'NotFound',
|
||||
'NOT_SETUP' = 'NotSetup',
|
||||
'UNAUTHORIZED' = 'Unauthorized',
|
||||
'UNKNOWN_ERROR' = 'UnknownError',
|
||||
'RETRY_EXCEPTION' = 'RetryException',
|
||||
'SCHEMA_FAILED' = 'SchemaFailed',
|
||||
'TOO_MANY_REQUESTS' = 'TooManyRequests',
|
||||
'PROFILE_URL_TAKEN' = 'ProfileUrlTaken',
|
||||
'PREMIUM_PROFILE_URL' = 'PremiumProfileUrl',
|
||||
}
|
||||
|
||||
const genericErrorCodeToTrpcErrorCodeMap: Record<string, TRPCError['code']> = {
|
||||
[AppErrorCode.ALREADY_EXISTS]: 'BAD_REQUEST',
|
||||
[AppErrorCode.EXPIRED_CODE]: 'BAD_REQUEST',
|
||||
[AppErrorCode.INVALID_BODY]: 'BAD_REQUEST',
|
||||
[AppErrorCode.INVALID_REQUEST]: 'BAD_REQUEST',
|
||||
[AppErrorCode.NOT_FOUND]: 'NOT_FOUND',
|
||||
[AppErrorCode.NOT_SETUP]: 'BAD_REQUEST',
|
||||
[AppErrorCode.UNAUTHORIZED]: 'UNAUTHORIZED',
|
||||
[AppErrorCode.UNKNOWN_ERROR]: 'INTERNAL_SERVER_ERROR',
|
||||
[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({
|
||||
code: z.string(),
|
||||
message: z.string().optional(),
|
||||
userMessage: z.string().optional(),
|
||||
});
|
||||
|
||||
export type TAppErrorJsonSchema = z.infer<typeof ZAppErrorJsonSchema>;
|
||||
|
||||
export class AppError extends Error {
|
||||
/**
|
||||
* The error code.
|
||||
*/
|
||||
code: string;
|
||||
|
||||
/**
|
||||
* An error message which can be displayed to the user.
|
||||
*/
|
||||
userMessage?: string;
|
||||
|
||||
/**
|
||||
* Create a new AppError.
|
||||
*
|
||||
* @param errorCode A string representing the error code.
|
||||
* @param message An internal error message.
|
||||
* @param userMessage A error message which can be displayed to the user.
|
||||
*/
|
||||
public constructor(errorCode: string, message?: string, userMessage?: string) {
|
||||
super(message || errorCode);
|
||||
this.code = errorCode;
|
||||
this.userMessage = userMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an unknown value into an AppError.
|
||||
*
|
||||
* @param error An unknown type.
|
||||
*/
|
||||
static parseError(error: unknown): AppError {
|
||||
if (error instanceof AppError) {
|
||||
return error;
|
||||
}
|
||||
|
||||
// Handle TRPC errors.
|
||||
if (error instanceof TRPCClientError) {
|
||||
const parsedJsonError = AppError.parseFromJSONString(error.message);
|
||||
return parsedJsonError || new AppError('UnknownError', error.message);
|
||||
}
|
||||
|
||||
// Handle completely unknown errors.
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const { code, message, userMessage } = error as {
|
||||
code: unknown;
|
||||
message: unknown;
|
||||
status: unknown;
|
||||
userMessage: unknown;
|
||||
};
|
||||
|
||||
const validCode: string | null = typeof code === 'string' ? code : AppErrorCode.UNKNOWN_ERROR;
|
||||
const validMessage: string | undefined = typeof message === 'string' ? message : undefined;
|
||||
const validUserMessage: string | undefined =
|
||||
typeof userMessage === 'string' ? userMessage : undefined;
|
||||
|
||||
return new AppError(validCode, validMessage, validUserMessage);
|
||||
}
|
||||
|
||||
static parseErrorToTRPCError(error: unknown): TRPCError {
|
||||
const appError = AppError.parseError(error);
|
||||
|
||||
return new TRPCError({
|
||||
code: genericErrorCodeToTrpcErrorCodeMap[appError.code] || 'BAD_REQUEST',
|
||||
message: AppError.toJSONString(appError),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an AppError into a JSON object which represents the error.
|
||||
*
|
||||
* @param appError The AppError to convert to JSON.
|
||||
* @returns A JSON object representing the AppError.
|
||||
*/
|
||||
static toJSON({ code, message, userMessage }: AppError): TAppErrorJsonSchema {
|
||||
return {
|
||||
code,
|
||||
message,
|
||||
userMessage,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an AppError into a JSON string containing the relevant information.
|
||||
*
|
||||
* @param appError The AppError to stringify.
|
||||
* @returns A JSON string representing the AppError.
|
||||
*/
|
||||
static toJSONString(appError: AppError): string {
|
||||
return JSON.stringify(AppError.toJSON(appError));
|
||||
}
|
||||
|
||||
static parseFromJSONString(jsonString: string): AppError | null {
|
||||
const parsed = ZAppErrorJsonSchema.safeParse(JSON.parse(jsonString));
|
||||
|
||||
if (!parsed.success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AppError(parsed.data.code, parsed.data.message, parsed.data.userMessage);
|
||||
}
|
||||
}
|
||||
@ -1,18 +1,23 @@
|
||||
/// <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';
|
||||
import CredentialsProvider from 'next-auth/providers/credentials';
|
||||
import type { GoogleProfile } from 'next-auth/providers/google';
|
||||
import GoogleProvider from 'next-auth/providers/google';
|
||||
import { env } from 'next-runtime-env';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { IdentityProvider, UserSecurityAuditLogType } from '@documenso/prisma/client';
|
||||
|
||||
import { isTwoFactorAuthenticationEnabled } from '../server-only/2fa/is-2fa-availble';
|
||||
import { validateTwoFactorAuthentication } from '../server-only/2fa/validate-2fa';
|
||||
import { getMostRecentVerificationTokenByUserId } from '../server-only/user/get-most-recent-verification-token-by-user-id';
|
||||
import { getUserByEmail } from '../server-only/user/get-user-by-email';
|
||||
import { sendConfirmationToken } from '../server-only/user/send-confirmation-token';
|
||||
import { extractNextAuthRequestMetadata } from '../universal/extract-request-metadata';
|
||||
import { ErrorCode } from './error-codes';
|
||||
|
||||
export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
||||
@ -34,7 +39,7 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
||||
},
|
||||
backupCode: { label: 'Backup Code', type: 'input', placeholder: 'Two-factor backup code' },
|
||||
},
|
||||
authorize: async (credentials, _req) => {
|
||||
authorize: async (credentials, req) => {
|
||||
if (!credentials) {
|
||||
throw new Error(ErrorCode.CREDENTIALS_NOT_FOUND);
|
||||
}
|
||||
@ -50,8 +55,18 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
||||
}
|
||||
|
||||
const isPasswordsSame = await compare(password, user.password);
|
||||
const requestMetadata = extractNextAuthRequestMetadata(req);
|
||||
|
||||
if (!isPasswordsSame) {
|
||||
await prisma.userSecurityAuditLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
ipAddress: requestMetadata.ipAddress,
|
||||
userAgent: requestMetadata.userAgent,
|
||||
type: UserSecurityAuditLogType.SIGN_IN_FAIL,
|
||||
},
|
||||
});
|
||||
|
||||
throw new Error(ErrorCode.INCORRECT_EMAIL_PASSWORD);
|
||||
}
|
||||
|
||||
@ -61,6 +76,15 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
||||
const isValid = await validateTwoFactorAuthentication({ backupCode, totpCode, user });
|
||||
|
||||
if (!isValid) {
|
||||
await prisma.userSecurityAuditLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
ipAddress: requestMetadata.ipAddress,
|
||||
userAgent: requestMetadata.userAgent,
|
||||
type: UserSecurityAuditLogType.SIGN_IN_2FA_FAIL,
|
||||
},
|
||||
});
|
||||
|
||||
throw new Error(
|
||||
totpCode
|
||||
? ErrorCode.INCORRECT_TWO_FACTOR_CODE
|
||||
@ -69,6 +93,22 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
||||
}
|
||||
}
|
||||
|
||||
if (!user.emailVerified) {
|
||||
const mostRecentToken = await getMostRecentVerificationTokenByUserId({
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
if (
|
||||
!mostRecentToken ||
|
||||
mostRecentToken.expires.valueOf() <= Date.now() ||
|
||||
DateTime.fromJSDate(mostRecentToken.createdAt).diffNow('minutes').minutes > -5
|
||||
) {
|
||||
await sendConfirmationToken({ email });
|
||||
}
|
||||
|
||||
throw new Error(ErrorCode.UNVERIFIED_EMAIL);
|
||||
}
|
||||
|
||||
return {
|
||||
id: Number(user.id),
|
||||
email: user.email,
|
||||
@ -93,7 +133,7 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
||||
}),
|
||||
],
|
||||
callbacks: {
|
||||
async jwt({ token, user }) {
|
||||
async jwt({ token, user, trigger, account }) {
|
||||
const merged = {
|
||||
...token,
|
||||
...user,
|
||||
@ -138,6 +178,22 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
||||
merged.emailVerified = user.emailVerified?.toISOString() ?? null;
|
||||
}
|
||||
|
||||
if ((trigger === 'signIn' || trigger === 'signUp') && account?.provider === 'google') {
|
||||
merged.emailVerified = user?.emailVerified
|
||||
? new Date(user.emailVerified).toISOString()
|
||||
: new Date().toISOString();
|
||||
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: Number(merged.id),
|
||||
},
|
||||
data: {
|
||||
emailVerified: merged.emailVerified,
|
||||
identityProvider: IdentityProvider.GOOGLE,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: merged.id,
|
||||
name: merged.name,
|
||||
@ -166,7 +222,7 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
||||
async signIn({ user }) {
|
||||
// We do this to stop OAuth providers from creating an account
|
||||
// when signups are disabled
|
||||
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === 'true') {
|
||||
if (env('NEXT_PUBLIC_DISABLE_SIGNUP') === 'true') {
|
||||
const userData = await getUserByEmail({ email: user.email! });
|
||||
|
||||
return !!userData;
|
||||
@ -175,4 +231,5 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
||||
return true;
|
||||
},
|
||||
},
|
||||
// Note: `events` are handled in `apps/web/src/pages/api/auth/[...nextauth].ts` to allow access to the request.
|
||||
};
|
||||
|
||||
@ -19,4 +19,5 @@ export const ErrorCode = {
|
||||
INCORRECT_PASSWORD: 'INCORRECT_PASSWORD',
|
||||
MISSING_ENCRYPTION_KEY: 'MISSING_ENCRYPTION_KEY',
|
||||
MISSING_BACKUP_CODE: 'MISSING_BACKUP_CODE',
|
||||
UNVERIFIED_EMAIL: 'UNVERIFIED_EMAIL',
|
||||
} as const;
|
||||
|
||||
@ -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,21 +1,25 @@
|
||||
import { compare } from 'bcrypt';
|
||||
import { compare } from '@node-rs/bcrypt';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { User } from '@documenso/prisma/client';
|
||||
import type { User } from '@documenso/prisma/client';
|
||||
import { UserSecurityAuditLogType } from '@documenso/prisma/client';
|
||||
|
||||
import { ErrorCode } from '../../next-auth/error-codes';
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { validateTwoFactorAuthentication } from './validate-2fa';
|
||||
|
||||
type DisableTwoFactorAuthenticationOptions = {
|
||||
user: User;
|
||||
backupCode: string;
|
||||
password: string;
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
|
||||
export const disableTwoFactorAuthentication = async ({
|
||||
backupCode,
|
||||
user,
|
||||
password,
|
||||
requestMetadata,
|
||||
}: DisableTwoFactorAuthenticationOptions) => {
|
||||
if (!user.password) {
|
||||
throw new Error(ErrorCode.USER_MISSING_PASSWORD);
|
||||
@ -33,15 +37,26 @@ export const disableTwoFactorAuthentication = async ({
|
||||
throw new Error(ErrorCode.INCORRECT_TWO_FACTOR_BACKUP_CODE);
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
twoFactorEnabled: false,
|
||||
twoFactorBackupCodes: null,
|
||||
twoFactorSecret: null,
|
||||
},
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
twoFactorEnabled: false,
|
||||
twoFactorBackupCodes: null,
|
||||
twoFactorSecret: null,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.userSecurityAuditLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
type: UserSecurityAuditLogType.AUTH_2FA_DISABLE,
|
||||
userAgent: requestMetadata?.userAgent,
|
||||
ipAddress: requestMetadata?.ipAddress,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return true;
|
||||
|
||||
@ -1,18 +1,21 @@
|
||||
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { User } from '@documenso/prisma/client';
|
||||
import { type User, UserSecurityAuditLogType } from '@documenso/prisma/client';
|
||||
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { getBackupCodes } from './get-backup-code';
|
||||
import { verifyTwoFactorAuthenticationToken } from './verify-2fa-token';
|
||||
|
||||
type EnableTwoFactorAuthenticationOptions = {
|
||||
user: User;
|
||||
code: string;
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
|
||||
export const enableTwoFactorAuthentication = async ({
|
||||
user,
|
||||
code,
|
||||
requestMetadata,
|
||||
}: EnableTwoFactorAuthenticationOptions) => {
|
||||
if (user.identityProvider !== 'DOCUMENSO') {
|
||||
throw new Error(ErrorCode.INCORRECT_IDENTITY_PROVIDER);
|
||||
@ -32,13 +35,24 @@ export const enableTwoFactorAuthentication = async ({
|
||||
throw new Error(ErrorCode.INCORRECT_TWO_FACTOR_CODE);
|
||||
}
|
||||
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
twoFactorEnabled: true,
|
||||
},
|
||||
const updatedUser = await prisma.$transaction(async (tx) => {
|
||||
await tx.userSecurityAuditLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
type: UserSecurityAuditLogType.AUTH_2FA_ENABLE,
|
||||
userAgent: requestMetadata?.userAgent,
|
||||
ipAddress: requestMetadata?.ipAddress,
|
||||
},
|
||||
});
|
||||
|
||||
return await tx.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
twoFactorEnabled: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const recoveryCodes = getBackupCodes({ user: updatedUser });
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import { compare } from '@node-rs/bcrypt';
|
||||
import { base32 } from '@scure/base';
|
||||
import { compare } from 'bcrypt';
|
||||
import crypto from 'crypto';
|
||||
import { createTOTPKeyURI } from 'oslo/otp';
|
||||
|
||||
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { User } from '@documenso/prisma/client';
|
||||
import { type User } from '@documenso/prisma/client';
|
||||
|
||||
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
|
||||
import { symmetricEncrypt } from '../../universal/crypto';
|
||||
@ -43,7 +43,7 @@ export const setupTwoFactorAuthentication = async ({
|
||||
|
||||
const secret = crypto.randomBytes(10);
|
||||
|
||||
const backupCodes = new Array(10)
|
||||
const backupCodes = Array.from({ length: 10 })
|
||||
.fill(null)
|
||||
.map(() => crypto.randomBytes(5).toString('hex'))
|
||||
.map((code) => `${code.slice(0, 5)}-${code.slice(5)}`.toUpperCase());
|
||||
|
||||
@ -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
@ -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;
|
||||
};
|
||||