From a1215df91a186a36b2acdbbb463f09a1c8304daf Mon Sep 17 00:00:00 2001 From: Mythie Date: Sun, 31 Dec 2023 13:58:15 +1100 Subject: [PATCH] refactor: extract api implementation to package Extracts the API implementation to a package so we can potentially reuse it across different applications in the event that we move off using a Next.js API route. Additionally tidies up the tokens page and form to be more simplified. --- .vscode/settings.json | 2 +- apps/web/package.json | 1 + .../app/(dashboard)/settings/token/page.tsx | 55 +++- .../settings/token/delete-token-dialog.tsx | 52 ++-- apps/web/src/components/forms/token.tsx | 157 ++++------ apps/web/src/pages/api/v1/[...ts-rest].tsx | 230 +-------------- package-lock.json | 268 +++++++++++++++--- packages/api/index.ts | 1 + packages/api/next.ts | 1 + packages/api/package.json | 28 ++ packages/api/tsconfig.json | 8 + packages/api/v1/contract.ts | 84 ++++++ packages/api/v1/implementation.ts | 178 ++++++++++++ packages/api/v1/middleware/authenticated.ts | 37 +++ packages/api/v1/schema.ts | 87 ++++++ .../public-api/get-all-user-tokens.ts | 5 +- .../trpc/server/api-token-router/schema.ts | 6 + 17 files changed, 802 insertions(+), 398 deletions(-) create mode 100644 packages/api/index.ts create mode 100644 packages/api/next.ts create mode 100644 packages/api/package.json create mode 100644 packages/api/tsconfig.json create mode 100644 packages/api/v1/contract.ts create mode 100644 packages/api/v1/implementation.ts create mode 100644 packages/api/v1/middleware/authenticated.ts create mode 100644 packages/api/v1/schema.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 97d5d1948..82aa3c1a3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,7 @@ { "typescript.tsdk": "node_modules/typescript/lib", "editor.codeActionsOnSave": { - "source.fixAll.eslint": true + "source.fixAll.eslint": "explicit" }, "eslint.validate": ["typescript", "typescriptreact", "javascript", "javascriptreact"], "javascript.preferences.importModuleSpecifier": "non-relative", diff --git a/apps/web/package.json b/apps/web/package.json index 150982c2d..bc32298b2 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,6 +14,7 @@ "copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs" }, "dependencies": { + "@documenso/api": "*", "@documenso/assets": "*", "@documenso/ee": "*", "@documenso/lib": "*", diff --git a/apps/web/src/app/(dashboard)/settings/token/page.tsx b/apps/web/src/app/(dashboard)/settings/token/page.tsx index 889e7a2a8..86143c633 100644 --- a/apps/web/src/app/(dashboard)/settings/token/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/token/page.tsx @@ -1,9 +1,21 @@ +import { DateTime } from 'luxon'; + +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import { getUserTokens } from '@documenso/lib/server-only/public-api/get-all-user-tokens'; +import { Button } from '@documenso/ui/primitives/button'; + +import DeleteTokenDialog from '~/components/(dashboard)/settings/token/delete-token-dialog'; +import { LocaleDate } from '~/components/formatter/locale-date'; import { ApiTokenForm } from '~/components/forms/token'; -export default function ApiToken() { +export default async function ApiTokensPage() { + const { user } = await getRequiredServerComponentSession(); + + const tokens = await getUserTokens({ userId: user.id }); + return (
-

API Tokens

+

API Tokens

On this page, you can create new API tokens and manage the existing ones. @@ -12,6 +24,45 @@ export default function ApiToken() {


+ +
+ +

Your existing tokens

+ + {tokens.length === 0 && ( +
+

+ Your tokens will be shown here once you create them. +

+
+ )} + + {tokens.length > 0 && ( +
+ {tokens.map((token) => ( +
+
+
+
{token.name}
+ +

+ Created on +

+

+ Expires on +

+
+ +
+ + + +
+
+
+ ))} +
+ )}
); } diff --git a/apps/web/src/components/(dashboard)/settings/token/delete-token-dialog.tsx b/apps/web/src/components/(dashboard)/settings/token/delete-token-dialog.tsx index b3f57018b..ba0a4cc99 100644 --- a/apps/web/src/components/(dashboard)/settings/token/delete-token-dialog.tsx +++ b/apps/web/src/components/(dashboard)/settings/token/delete-token-dialog.tsx @@ -1,3 +1,5 @@ +'use client'; + import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; @@ -6,6 +8,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; +import type { ApiToken } from '@documenso/prisma/client'; import { trpc } from '@documenso/trpc/react'; import { Button } from '@documenso/ui/primitives/button'; import { @@ -29,24 +32,18 @@ import { Input } from '@documenso/ui/primitives/input'; import { useToast } from '@documenso/ui/primitives/use-toast'; export type DeleteTokenDialogProps = { - trigger?: React.ReactNode; - tokenId: number; - tokenName: string; - onDelete: () => void; + token: Pick; + onDelete?: () => void; + children?: React.ReactNode; }; -export default function DeleteTokenDialog({ - trigger, - tokenId, - tokenName, - onDelete, -}: DeleteTokenDialogProps) { +export default function DeleteTokenDialog({ token, onDelete, children }: DeleteTokenDialogProps) { const router = useRouter(); const { toast } = useToast(); - const [isOpen, setIsOpen] = useState(false); - const [isDeleteEnabled, setIsDeleteEnabled] = useState(false); - const deleteMessage = `delete ${tokenName}`; + const [isOpen, setIsOpen] = useState(false); + + const deleteMessage = `delete ${token.name}`; const ZDeleteTokenDialogSchema = z.object({ tokenName: z.literal(deleteMessage, { @@ -58,7 +55,7 @@ export default function DeleteTokenDialog({ const { mutateAsync: deleteTokenMutation } = trpc.apiToken.deleteTokenById.useMutation({ onSuccess() { - onDelete(); + onDelete?.(); }, }); @@ -69,14 +66,10 @@ export default function DeleteTokenDialog({ }, }); - const onInputChange = (event: React.ChangeEvent) => { - setIsDeleteEnabled(event.target.value === deleteMessage); - }; - const onSubmit = async () => { try { await deleteTokenMutation({ - id: tokenId, + id: token.id, }); toast({ @@ -86,7 +79,8 @@ export default function DeleteTokenDialog({ }); setIsOpen(false); - router.push('/settings/token'); + + router.refresh(); } catch (error) { toast({ title: 'An unknown error occurred', @@ -100,7 +94,6 @@ export default function DeleteTokenDialog({ useEffect(() => { if (!isOpen) { - setIsDeleteEnabled(false); form.reset(); } }, [isOpen, form]); @@ -111,12 +104,13 @@ export default function DeleteTokenDialog({ onOpenChange={(value) => !form.formState.isSubmitting && setIsOpen(value)} > - {trigger ?? ( + {children ?? ( )} + Are you sure you want to delete this token? @@ -144,21 +138,15 @@ export default function DeleteTokenDialog({ {deleteMessage} + - { - onInputChange(value); - field.onChange(value); - }} - /> + )} /> +
-
- )} - -

Create a new token

-

- Enter a representative name for your new token. -

-
+
( - + Token Name - - - + +
+ + + + + +
+ + + Please enter a meaningful name for your token. This will help you identify it + later. + +
)} /> -
+
+ + {newlyCreatedToken && ( + + +

+ Your token was created successfully! Make sure to copy it because you won't be able to + see it again! +

+ +

+ {newlyCreatedToken} +

+ + +
+
+ )} ); }; diff --git a/apps/web/src/pages/api/v1/[...ts-rest].tsx b/apps/web/src/pages/api/v1/[...ts-rest].tsx index 0b22d97c6..15b618ebd 100644 --- a/apps/web/src/pages/api/v1/[...ts-rest].tsx +++ b/apps/web/src/pages/api/v1/[...ts-rest].tsx @@ -1,227 +1,5 @@ -import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta'; -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 { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document'; -import { getUserByApiToken } from '@documenso/lib/server-only/public-api/get-user-by-token'; -import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document'; -import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions'; -import { contract } from '@documenso/trpc/api-contract/contract'; -import { createNextRoute, createNextRouter } from '@documenso/trpc/server/public-api/ts-rest'; +import { createNextRouter } from '@documenso/api/next'; +import { ApiContractV1 } from '@documenso/api/v1/contract'; +import { ApiContractV1Implementation } from '@documenso/api/v1/implementation'; -const router = createNextRoute(contract, { - getDocuments: async (args) => { - const page = Number(args.query.page) || 1; - const perPage = Number(args.query.perPage) || 10; - const { authorization } = args.headers; - let user; - - try { - user = await getUserByApiToken({ token: authorization }); - } catch (e) { - return { - status: 401, - body: { - message: e.message, - }, - }; - } - - const { data: documents, totalPages } = await findDocuments({ page, perPage, userId: user.id }); - - return { - status: 200, - body: { - documents, - totalPages, - }, - }; - }, - getDocument: async (args) => { - const { id: documentId } = args.params; - const { authorization } = args.headers; - let user; - - try { - user = await getUserByApiToken({ token: authorization }); - } catch (e) { - return { - status: 401, - body: { - message: e.message, - }, - }; - } - - try { - const document = await getDocumentById({ id: Number(documentId), userId: user.id }); - - return { - status: 200, - body: document, - }; - } catch (e) { - return { - status: 404, - body: { - message: e.message ?? 'Document not found', - }, - }; - } - }, - deleteDocument: async (args) => { - const { id: documentId } = args.params; - const { authorization } = args.headers; - - let user; - - try { - user = await getUserByApiToken({ token: authorization }); - } catch (e) { - return { - status: 401, - body: { - message: e.message, - }, - }; - } - - try { - const document = await getDocumentById({ id: Number(documentId), userId: user.id }); - - const deletedDocument = await deleteDocument({ - id: Number(documentId), - userId: user.id, - status: document.status, - }); - - return { - status: 200, - body: deletedDocument, - }; - } catch (e) { - return { - status: 404, - body: { - message: e.message ?? 'Document not found', - }, - }; - } - }, - createDocument: async (args) => { - const { body } = args; - - try { - const { url, key } = await getPresignPostUrl(body.fileName, body.contentType); - - return { - status: 200, - body: { - url, - key, - }, - }; - } catch (e) { - return { - status: 404, - body: { - message: e.message ?? 'An error has occured while uploading the file', - }, - }; - } - }, - sendDocumentForSigning: async (args) => { - const { authorization } = args.headers; - const { id } = args.params; - const { body } = args; - let user; - - try { - user = await getUserByApiToken({ token: authorization }); - } catch (e) { - return { - status: 401, - body: { - message: e.message, - }, - }; - } - - const document = await getDocumentById({ id: Number(id), userId: user.id }); - - if (!document) { - return { - status: 404, - body: { - message: 'Document not found', - }, - }; - } - - if (document.status === 'PENDING') { - return { - status: 400, - body: { - message: 'Document is already waiting for signing', - }, - }; - } - - 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, - }); - - return { - status: 200, - body: { - message: 'Document sent for signing successfully', - }, - }; - } catch (e) { - return { - status: 500, - body: { - message: e.message ?? 'An error has occured while sending the document for signing', - }, - }; - } - }, -}); - -export default createNextRouter(contract, router); +export default createNextRouter(ApiContractV1, ApiContractV1Implementation); diff --git a/package-lock.json b/package-lock.json index d244df9e8..148b02096 100644 --- a/package-lock.json +++ b/package-lock.json @@ -88,6 +88,7 @@ "version": "1.2.3", "license": "AGPL-3.0", "dependencies": { + "@documenso/api": "*", "@documenso/assets": "*", "@documenso/ee": "*", "@documenso/lib": "*", @@ -167,18 +168,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@anatine/zod-openapi": { - "version": "1.14.2", - "resolved": "https://registry.npmjs.org/@anatine/zod-openapi/-/zod-openapi-1.14.2.tgz", - "integrity": "sha512-q0qHfnuNYVKu0Swrnnvfj9971AEyW7c8v9jCOZGCl5ZbyGMNG4RPyJkRcMi/JC8CRfdOe0IDfNm1nNsi2avprg==", - "dependencies": { - "ts-deepmerge": "^6.0.3" - }, - "peerDependencies": { - "openapi3-ts": "^2.0.0 || ^3.0.0", - "zod": "^3.20.0" - } - }, "node_modules/@aws-crypto/crc32": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-3.0.0.tgz", @@ -1776,6 +1765,10 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@documenso/api": { + "resolved": "packages/api", + "link": true + }, "node_modules/@documenso/app-tests": { "resolved": "packages/app-tests", "link": true @@ -14379,22 +14372,6 @@ "node": ">= 14.17.0" } }, - "node_modules/openapi3-ts": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/openapi3-ts/-/openapi3-ts-2.0.2.tgz", - "integrity": "sha512-TxhYBMoqx9frXyOgnRHufjQfPXomTIHYKhSKJ6jHfj13kS8OEIhvmE8CTuQyKtjjWttAjX5DPxM1vmalEpo8Qw==", - "dependencies": { - "yaml": "^1.10.2" - } - }, - "node_modules/openapi3-ts/node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", - "engines": { - "node": ">= 6" - } - }, "node_modules/openid-client": { "version": "5.6.1", "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.6.1.tgz", @@ -17858,14 +17835,6 @@ "typescript": ">=4.2.0" } }, - "node_modules/ts-deepmerge": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/ts-deepmerge/-/ts-deepmerge-6.2.0.tgz", - "integrity": "sha512-2qxI/FZVDPbzh63GwWIZYE7daWKtwXZYuyc8YNq0iTmMUwn4mL0jRLsp6hfFlgbdRSR4x2ppe+E86FnvEpN7Nw==", - "engines": { - "node": ">=14.13.1" - } - }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -19268,6 +19237,233 @@ "url": "https://github.com/sponsors/wooorm" } }, + "packages/api": { + "name": "@documenso/api", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@documenso/lib": "*", + "@documenso/prisma": "*", + "@ts-rest/core": "^3.30.5", + "@ts-rest/next": "^3.30.5", + "luxon": "^3.4.0", + "superjson": "^1.13.1", + "ts-pattern": "^5.0.5", + "zod": "^3.22.4" + }, + "devDependencies": {} + }, + "packages/api/node_modules/@next/env": { + "version": "13.5.6", + "resolved": "https://registry.npmjs.org/@next/env/-/env-13.5.6.tgz", + "integrity": "sha512-Yac/bV5sBGkkEXmAX5FWPS9Mmo2rthrOPRQQNfycJPkjUAUclomCPH7QFVCDQ4Mp2k2K1SSM6m0zrxYrOwtFQw==", + "peer": true + }, + "packages/api/node_modules/@next/swc-darwin-arm64": { + "version": "13.5.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-13.5.6.tgz", + "integrity": "sha512-5nvXMzKtZfvcu4BhtV0KH1oGv4XEW+B+jOfmBdpFI3C7FrB/MfujRpWYSBBO64+qbW8pkZiSyQv9eiwnn5VIQA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "packages/api/node_modules/@next/swc-darwin-x64": { + "version": "13.5.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-13.5.6.tgz", + "integrity": "sha512-6cgBfxg98oOCSr4BckWjLLgiVwlL3vlLj8hXg2b+nDgm4bC/qVXXLfpLB9FHdoDu4057hzywbxKvmYGmi7yUzA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "packages/api/node_modules/@next/swc-linux-arm64-gnu": { + "version": "13.5.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-13.5.6.tgz", + "integrity": "sha512-txagBbj1e1w47YQjcKgSU4rRVQ7uF29YpnlHV5xuVUsgCUf2FmyfJ3CPjZUvpIeXCJAoMCFAoGnbtX86BK7+sg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "packages/api/node_modules/@next/swc-linux-arm64-musl": { + "version": "13.5.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-13.5.6.tgz", + "integrity": "sha512-cGd+H8amifT86ZldVJtAKDxUqeFyLWW+v2NlBULnLAdWsiuuN8TuhVBt8ZNpCqcAuoruoSWynvMWixTFcroq+Q==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "packages/api/node_modules/@next/swc-linux-x64-gnu": { + "version": "13.5.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-13.5.6.tgz", + "integrity": "sha512-Mc2b4xiIWKXIhBy2NBTwOxGD3nHLmq4keFk+d4/WL5fMsB8XdJRdtUlL87SqVCTSaf1BRuQQf1HvXZcy+rq3Nw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "packages/api/node_modules/@next/swc-linux-x64-musl": { + "version": "13.5.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-13.5.6.tgz", + "integrity": "sha512-CFHvP9Qz98NruJiUnCe61O6GveKKHpJLloXbDSWRhqhkJdZD2zU5hG+gtVJR//tyW897izuHpM6Gtf6+sNgJPQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "packages/api/node_modules/@next/swc-win32-arm64-msvc": { + "version": "13.5.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-13.5.6.tgz", + "integrity": "sha512-aFv1ejfkbS7PUa1qVPwzDHjQWQtknzAZWGTKYIAaS4NMtBlk3VyA6AYn593pqNanlicewqyl2jUhQAaFV/qXsg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "packages/api/node_modules/@next/swc-win32-ia32-msvc": { + "version": "13.5.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-13.5.6.tgz", + "integrity": "sha512-XqqpHgEIlBHvzwG8sp/JXMFkLAfGLqkbVsyN+/Ih1mR8INb6YCc2x/Mbwi6hsAgUnqQztz8cvEbHJUbSl7RHDg==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "packages/api/node_modules/@next/swc-win32-x64-msvc": { + "version": "13.5.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-13.5.6.tgz", + "integrity": "sha512-Cqfe1YmOS7k+5mGu92nl5ULkzpKuxJrP3+4AEuPmrpFZ3BHxTY3TnHmU1On3bFmFFs6FbTcdF58CCUProGpIGQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "packages/api/node_modules/@ts-rest/next": { + "version": "3.30.5", + "resolved": "https://registry.npmjs.org/@ts-rest/next/-/next-3.30.5.tgz", + "integrity": "sha512-NasfUN7SnwcjJNbxvvcemC4fOv4f4IF5I14wVqQODN0HWPokkrta6XLuv0eKQJYdB32AS7VINQhls8Sj1AIN0g==", + "peerDependencies": { + "@ts-rest/core": "3.30.5", + "next": "^12.0.0 || ^13.0.0", + "zod": "^3.22.3" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "packages/api/node_modules/next": { + "version": "13.5.6", + "resolved": "https://registry.npmjs.org/next/-/next-13.5.6.tgz", + "integrity": "sha512-Y2wTcTbO4WwEsVb4A8VSnOsG1I9ok+h74q0ZdxkwM3EODqrs4pasq7O0iUxbcS9VtWMicG7f3+HAj0r1+NtKSw==", + "peer": true, + "dependencies": { + "@next/env": "13.5.6", + "@swc/helpers": "0.5.2", + "busboy": "1.6.0", + "caniuse-lite": "^1.0.30001406", + "postcss": "8.4.31", + "styled-jsx": "5.1.1", + "watchpack": "2.4.0" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=16.14.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "13.5.6", + "@next/swc-darwin-x64": "13.5.6", + "@next/swc-linux-arm64-gnu": "13.5.6", + "@next/swc-linux-arm64-musl": "13.5.6", + "@next/swc-linux-x64-gnu": "13.5.6", + "@next/swc-linux-x64-musl": "13.5.6", + "@next/swc-win32-arm64-msvc": "13.5.6", + "@next/swc-win32-ia32-msvc": "13.5.6", + "@next/swc-win32-x64-msvc": "13.5.6" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, "packages/app-tests": { "name": "@documenso/app-tests", "version": "1.0.0", diff --git a/packages/api/index.ts b/packages/api/index.ts new file mode 100644 index 000000000..cb0ff5c3b --- /dev/null +++ b/packages/api/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/api/next.ts b/packages/api/next.ts new file mode 100644 index 000000000..5ac5aab45 --- /dev/null +++ b/packages/api/next.ts @@ -0,0 +1 @@ +export { createNextRouter } from '@ts-rest/next'; diff --git a/packages/api/package.json b/packages/api/package.json new file mode 100644 index 000000000..9aea9b26f --- /dev/null +++ b/packages/api/package.json @@ -0,0 +1,28 @@ +{ + "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", + "luxon": "^3.4.0", + "superjson": "^1.13.1", + "ts-pattern": "^5.0.5", + "zod": "^3.22.4" + }, + "devDependencies": {} +} diff --git a/packages/api/tsconfig.json b/packages/api/tsconfig.json new file mode 100644 index 000000000..dc21318a7 --- /dev/null +++ b/packages/api/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@documenso/tsconfig/react-library.json", + "include": ["."], + "exclude": ["dist", "build", "node_modules"], + "compilerOptions": { + "strict": true, + } +} diff --git a/packages/api/v1/contract.ts b/packages/api/v1/contract.ts new file mode 100644 index 000000000..0f853a020 --- /dev/null +++ b/packages/api/v1/contract.ts @@ -0,0 +1,84 @@ +import { initContract } from '@ts-rest/core'; + +import { + ZSendDocumentForSigningMutationSchema as SendDocumentMutationSchema, + ZAuthorizationHeadersSchema, + ZCreateDocumentMutationSchema, + ZDeleteDocumentMutationSchema, + ZGetDocumentsQuerySchema, + ZSuccessfulDocumentResponseSchema, + ZSuccessfulResponseSchema, + ZSuccessfulSigningResponseSchema, + ZUnsuccessfulResponseSchema, + ZUploadDocumentSuccessfulSchema, +} from './schema'; + +const c = initContract(); + +export const ApiContractV1 = c.router( + { + getDocuments: { + method: 'GET', + path: '/documents', + query: ZGetDocumentsQuerySchema, + responses: { + 200: ZSuccessfulResponseSchema, + 401: ZUnsuccessfulResponseSchema, + 404: ZUnsuccessfulResponseSchema, + }, + summary: 'Get all documents', + }, + + getDocument: { + method: 'GET', + path: `/documents/:id`, + responses: { + 200: ZSuccessfulDocumentResponseSchema, + 401: ZUnsuccessfulResponseSchema, + 404: ZUnsuccessfulResponseSchema, + }, + summary: 'Get a single document', + }, + + createDocument: { + method: 'POST', + path: '/documents', + body: ZCreateDocumentMutationSchema, + responses: { + 200: ZUploadDocumentSuccessfulSchema, + 401: ZUnsuccessfulResponseSchema, + 404: ZUnsuccessfulResponseSchema, + }, + summary: 'Upload a new document and get a presigned URL', + }, + + sendDocument: { + method: 'PATCH', + path: '/documents/:id/send', + body: SendDocumentMutationSchema, + responses: { + 200: ZSuccessfulSigningResponseSchema, + 400: ZUnsuccessfulResponseSchema, + 401: ZUnsuccessfulResponseSchema, + 404: ZUnsuccessfulResponseSchema, + 500: ZUnsuccessfulResponseSchema, + }, + summary: 'Send a document for signing', + }, + + deleteDocument: { + method: 'DELETE', + path: `/documents/:id`, + body: ZDeleteDocumentMutationSchema, + responses: { + 200: ZSuccessfulDocumentResponseSchema, + 401: ZUnsuccessfulResponseSchema, + 404: ZUnsuccessfulResponseSchema, + }, + summary: 'Delete a document', + }, + }, + { + baseHeaders: ZAuthorizationHeadersSchema, + }, +); diff --git a/packages/api/v1/implementation.ts b/packages/api/v1/implementation.ts new file mode 100644 index 000000000..b317e95d6 --- /dev/null +++ b/packages/api/v1/implementation.ts @@ -0,0 +1,178 @@ +import { createNextRoute } from '@ts-rest/next'; + +import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta'; +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 { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document'; +import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document'; +import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions'; + +import { ApiContractV1 } from './contract'; +import { authenticatedMiddleware } from './middleware/authenticated'; + +export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { + getDocuments: authenticatedMiddleware(async (args, user) => { + 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 }); + + return { + status: 200, + body: { + documents, + totalPages, + }, + }; + }), + + getDocument: authenticatedMiddleware(async (args, user) => { + const { id: documentId } = args.params; + + try { + const document = await getDocumentById({ id: Number(documentId), userId: user.id }); + + return { + status: 200, + body: document, + }; + } catch (err) { + return { + status: 404, + body: { + message: 'Document not found', + }, + }; + } + }), + + deleteDocument: authenticatedMiddleware(async (args, user) => { + const { id: documentId } = args.params; + + try { + const document = await getDocumentById({ id: Number(documentId), userId: user.id }); + + const deletedDocument = await deleteDocument({ + id: Number(documentId), + userId: user.id, + status: document.status, + }); + + return { + status: 200, + body: deletedDocument, + }; + } catch (err) { + return { + status: 404, + body: { + message: 'Document not found', + }, + }; + } + }), + + createDocument: authenticatedMiddleware(async (args, _user) => { + const { body } = args; + + try { + const { url, key } = await getPresignPostUrl(body.fileName, body.contentType); + + return { + status: 200, + body: { + url, + key, + }, + }; + } catch (err) { + return { + status: 404, + body: { + message: 'An error has occured while uploading the file', + }, + }; + } + }), + + sendDocument: authenticatedMiddleware(async (args, user) => { + const { id } = args.params; + const { body } = args; + + const document = await getDocumentById({ id: Number(id), userId: user.id }); + + if (!document) { + return { + status: 404, + body: { + message: 'Document not found', + }, + }; + } + + if (document.status === 'PENDING') { + return { + status: 400, + body: { + message: 'Document is already waiting for signing', + }, + }; + } + + 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, + }); + + 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', + }, + }; + } + }), +}); diff --git a/packages/api/v1/middleware/authenticated.ts b/packages/api/v1/middleware/authenticated.ts new file mode 100644 index 000000000..3e23029a5 --- /dev/null +++ b/packages/api/v1/middleware/authenticated.ts @@ -0,0 +1,37 @@ +import type { NextApiRequest } from 'next'; + +import { getUserByApiToken } from '@documenso/lib/server-only/public-api/get-user-by-token'; +import type { User } from '@documenso/prisma/client'; + +export const authenticatedMiddleware = < + T extends { + req: NextApiRequest; + }, + R extends { + status: number; + body: unknown; + }, +>( + handler: (args: T, user: User) => Promise, +) => { + return async (args: T) => { + try { + const { authorization: token } = args.req.headers; + + if (!token) { + throw new Error('Token was not provided for authenticated middleware'); + } + + const user = await getUserByApiToken({ token }); + + return await handler(args, user); + } catch (_err) { + return { + status: 401, + body: { + message: 'Unauthorized', + }, + } as const; + } + }; +}; diff --git a/packages/api/v1/schema.ts b/packages/api/v1/schema.ts new file mode 100644 index 000000000..f4c80ca73 --- /dev/null +++ b/packages/api/v1/schema.ts @@ -0,0 +1,87 @@ +import { z } from 'zod'; + +import { FieldType } from '@documenso/prisma/client'; + +export const ZGetDocumentsQuerySchema = z.object({ + page: z.string().optional(), + perPage: z.string().optional(), +}); + +export type TGetDocumentsQuerySchema = z.infer; + +export const ZDeleteDocumentMutationSchema = z.string(); + +export type TDeleteDocumentMutationSchema = z.infer; + +export const ZSuccessfulDocumentResponseSchema = z.object({ + id: z.number(), + userId: z.number(), + title: z.string(), + status: z.string(), + documentDataId: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + completedAt: z.date().nullable(), +}); + +export type TSuccessfulDocumentResponseSchema = z.infer; + +export const ZSendDocumentForSigningMutationSchema = z.object({ + signerEmail: z.string(), + signerName: z.string().optional(), + emailSubject: z.string().optional(), + emailBody: z.string().optional(), + fields: z.array( + z.object({ + fieldType: z.nativeEnum(FieldType), + pageNumber: z.number(), + pageX: z.number(), + pageY: z.number(), + pageWidth: z.number(), + pageHeight: z.number(), + }), + ), +}); + +export type TSendDocumentForSigningMutationSchema = z.infer< + typeof ZSendDocumentForSigningMutationSchema +>; + +export const ZUploadDocumentSuccessfulSchema = z.object({ + url: z.string(), + key: z.string(), +}); + +export type TUploadDocumentSuccessfulSchema = z.infer; + +export const ZCreateDocumentMutationSchema = z.object({ + fileName: z.string(), + contentType: z.string().default('PDF'), +}); + +export type TCreateDocumentMutationSchema = z.infer; + +export const ZSuccessfulResponseSchema = z.object({ + documents: ZSuccessfulDocumentResponseSchema.array(), + totalPages: z.number(), +}); + +export type TSuccessfulResponseSchema = z.infer; + +export const ZSuccessfulSigningResponseSchema = z.object({ + message: z.string(), +}); + +export type TSuccessfulSigningResponseSchema = z.infer; + +export const ZUnsuccessfulResponseSchema = z.object({ + message: z.string(), +}); + +export type TUnsuccessfulResponseSchema = z.infer; + +export const ZAuthorizationHeadersSchema = z.object({ + authorization: z.string(), +}); + +export type TAuthorizationHeadersSchema = z.infer; diff --git a/packages/lib/server-only/public-api/get-all-user-tokens.ts b/packages/lib/server-only/public-api/get-all-user-tokens.ts index d64562b83..1ba31a6cf 100644 --- a/packages/lib/server-only/public-api/get-all-user-tokens.ts +++ b/packages/lib/server-only/public-api/get-all-user-tokens.ts @@ -5,7 +5,7 @@ export type GetUserTokensOptions = { }; export const getUserTokens = async ({ userId }: GetUserTokensOptions) => { - return prisma.apiToken.findMany({ + return await prisma.apiToken.findMany({ where: { userId, }, @@ -16,5 +16,8 @@ export const getUserTokens = async ({ userId }: GetUserTokensOptions) => { createdAt: true, expires: true, }, + orderBy: { + createdAt: 'desc', + }, }); }; diff --git a/packages/trpc/server/api-token-router/schema.ts b/packages/trpc/server/api-token-router/schema.ts index b615ef3af..c28920b9a 100644 --- a/packages/trpc/server/api-token-router/schema.ts +++ b/packages/trpc/server/api-token-router/schema.ts @@ -4,10 +4,16 @@ export const ZGetApiTokenByIdQuerySchema = z.object({ id: z.number().min(1), }); +export type TGetApiTokenByIdQuerySchema = z.infer; + export const ZCreateTokenMutationSchema = z.object({ tokenName: z.string().min(3, { message: 'The token name should be 3 characters or longer' }), }); +export type TCreateTokenMutationSchema = z.infer; + export const ZDeleteTokenByIdMutationSchema = z.object({ id: z.number().min(1), }); + +export type TDeleteTokenByIdMutationSchema = z.infer;