DEV Community

Cover image for Building a Contracts SaaS with SaasRock — Part 2 — Signing Contracts with Dropbox Sign
Alexandro Martinez
Alexandro Martinez

Posted on

Building a Contracts SaaS with SaasRock — Part 2 — Signing Contracts with Dropbox Sign

In this chapter, I’m going to create the Contracts module using SaasRock’s Entity Code Generator, customize the generated code, and implement the Dropbox Sign API using their official SDK for Node.

Check out part 1 here.

Chapter 2

  1. Modeling the Entity
  2. Autogenerating the Files for CRUD
  3. Creating the Signers Components
  4. Implementing the Signers Model
  5. Implementing the Dropbox Sign API

Cover

1. Modeling the Entity

Using SaasRock’s entity builder, we can quickly get basic CRUD functionality to get our MVP running. Here’s how I’m going to create the entity “contract”:

Contract’s Entity Definition

And I’ll create some properties: Name (text), Type (select), Description (optional text with rows), Document (pdf), Document signed (optional pdf), Attachments (optional files), Estimated Amount (number), Real Amount (optional number), Active (boolean), Estimated Completion Date (date), and Real Completion Date (optional date).

Contract Properties

And by the way, I’m going to copy an SVG icon from icons8, and add a property “fill=’currentColor’” like this (if you’re curious I’m using this one):

Contract Icon

With this model, I now have a full no-code CRUD functionality for contracts, check out the quick video demo here: https://www.loom.com/share/3a1b96fb691a4bfd9dd3c7a812cfe535

2. Autogenerating the Files for CRUD

Now that I’m happy with the autogenerated CRUD, I’m going to use the new Code Generator feature to download 23 files for my Contracts module.

Quick video demo of downloading the generated code or visit the URL of the generated files here: https://www.loom.com/share/cd3be70caf574bff9db0460abbcf5bfc

DTO, components, and utils (5 files)

These files handle our model properties for using a typed interface in both server and client code.

  • dtos/ContractDto.ts — Server <-> Client row interface
  • dtos/ContractCreateDto.ts — Dto for creating a row
  • components/ContractForm.tsx — Form with creating, reading, updating, and deleting states
  • helpers/ContractHelpers.ts — FormData and RowWithDetails transformer functions to Dto
  • services/ContractService.ts — CRUD operations

API Routes (6 files)

These API Routes are basically Remix Loader and Action functions to handle data loading for the client and to perform actions on the server.

  • routes/api/ContractRoutes.Index.Api.ts — Get all rows with pagination and filtering
  • routes/api/ContractRoutes.New.Api.ts — Create a new row
  • routes/api/ContractRoutes.Edit.Api.ts — Update row values
  • routes/api/ContractRoutes.Activity.Api.ts — Row history and comments
  • routes/api/ContractRoutes.Share.Api.ts — Share row with other accounts, users, roles, and groups
  • routes/api/ContractRoutes.Tags.Api.ts — Add or remove row tags

Views (6 files)

These files use their corresponding API to render the data and to submit actions (i.e. the Edit view loads a row, and can submit an “edit” action).

  • routes/views/ContractRoutes.Index.View.tsx — Table and quick overview
  • routes/views/ContractRoutes.New.View.tsx — Form for creating a row
  • routes/views/ContractRoutes.Edit.View.tsx — Form for viewing and editing
  • routes/views/ContractRoutes.Activity.View.tsx — Row history and comments
  • routes/views/ContractRoutes.Share.View.tsx — Share row with other accounts, users, roles, and groups
  • routes/views/ContractRoutes.Tags.View.tsx — Set row tags

Routes (6 files)

Finally, for each route (Index, New, Edit, Activity, Share, and Tags) there’s a route that orchestrates the API with its View, like in the following image:

Contracts Index Route

After generating the code, your git changes should look like in the following image. By the way, before generating code, commit any pending changes so you can roll back unwanted code.

Generated code in git changes

Before committing the generated code, run npm run prettier, this way you’ll have your prettier settings applied.

3. Creating the Signers Components

Each contract needs to be signed by a registered user, so I need to override my contracts module to require a list of signers on every created contract.

The first thing that I need to create is a “ContractSignersForm.tsx” component that allows adding signers (Email, Name, and Role):

ContractSignersForm.tsx

I’ll add this component at the bottom of the “ContractForm.tsx”:

Contract New Route using ContractSignersForm

Then, for read-only purposes a “ContractSignersList.tsx” component:

ContractSignersList.tsx

And this component will go in the “ContractRoutesEditView.tsx” autogenerated view component:

git changes

And this component will render like in the following image:

Contract Overview Route using ContractSignersList

4. Implementing the Signers Model

A database model is needed for saving each Contract signer. At the bottom of the “schema.prisma” file, I’ll add the following model:

+ model Signer {
+  id       String    @id @default(cuid())
+  rowId    String
+  row      Row       @relation(fields: [rowId], references: [id], onDelete: Cascade)
+  email    String
+  name     String
+  role     String
+  signedAt DateTime?
+}
Enter fullscreen mode Exit fullscreen mode

And the Row relationship needs to be set on the Row model (each contract is basically a row):

model Row {
  id                 String                  @id @default(cuid())
  ...
+ signers            Signer[]
}
Enter fullscreen mode Exit fullscreen mode

You can either run npx prisma migrate dev --name signers_model, or npx prisma db push.

Updating the ContractCreateDto interface

Now a new “signers” property is required:

export type ContractCreateDto = {
  name: string;
  type: string;
  description: string | undefined;
  document: MediaDto;
  attachments: MediaDto[] | undefined;
  estimatedAmount: number;
  active: boolean;
  estimatedCompletionDate: Date;
+ signers: { email: string; name: string; role: string; }[]
};
Enter fullscreen mode Exit fullscreen mode

Updating the ContractRoutes.New.Api action

Before calling the “ContractService.create(…)” function, let’s grab the signers from the form, and throw an error if no signers were set:

export namespace ContractRoutesNewApi {
  ...
  export const action: ActionFunction = async ({ request, params }) => {
  ...
  if (estimatedCompletionDate === undefined) throw new Error(t("Estimated Completion Date") + " is required");
+ const signers: { email: string; name: string; role: string; }[] = form.getAll("signers[]").map((f: FormDataEntryValue) => {
+    return JSON.parse(f.toString());
+ });
+ if (signers.filter((f) => f.role === "signer").length === 0) {
+   throw new Error("At least one signer is required");
+ }
+ const invalidSigners = signers.filter((f) => f.email === "" || f.name === "" || f.role === "");
+ if (invalidSigners.length > 0) {
+   throw new Error("Signer email, name and role are required");
+ }
  const item = await ContractService.create(
  ...
Enter fullscreen mode Exit fullscreen mode

Updating the ContractRoutes.New.Api action

Now that the validation is set, is time to save on the database inside the “ContractService.create(…)” implementation:

export namespace ContractService {
  ...
  export async function create(data: ContractCreateDto, session: { tenantId: string | null; userId?: string }): Promise<ContractDto> {
    ...
+   await Promise.all(
+      data.signers.map((signer) => {
+        return db.signer.create({
+          data: {
+            rowId: item.id,
+            email: signer.email,
+            name: signer.name,
+            role: signer.role,
+          },
+        });
+      })
+   );
    return ContractHelpers.rowToDto({ entity, row: item });
  }
  ...
}
Enter fullscreen mode Exit fullscreen mode

After these modifications, signers should be saved into the database when creating a contract at “/admin/entities/code-generator/tests/contracts/new”. But now let’s display the signers on our Edit view.

Updating the ContractDto interface

Same as I did with the “ContractCreateDto” but with the “id” and “signedAt” properties:

export type ContractCreateDto = {
  ...
+ signers: { id: string; email: string; name: string; role: string; signedAt: Date | null; }[]
};
Enter fullscreen mode Exit fullscreen mode

Now let’s load the signers in every row.

Updating RowWithDetails

Since signers are basically a row property, let’s modify the interface of RowWithDetails at the “app/utils/db/entities/rows.db.server.ts” file:

import { ...,
+ Signer 
} from "@prisma/client";
...
export type RowWithDetails = Row & {
  createdByUser: UserSimple | null;
  ...
+ signers: Signer[];
};
...
export const includeRowDetails = {
  ...
  permissions: true,
  sampleCustomEntity: true,
+ signers: true,
};
Enter fullscreen mode Exit fullscreen mode

Updating the ContractHelpers Row to Dto mapping

Every row will now have its signers, but we need to load them into the Dto object:

...
function rowToDto({ entity, row }: { entity: EntityWithDetails; row: RowWithDetails }): ContractDto {
  return {
    row,
    ...
    realCompletionDate: RowValueHelper.getDate({ entity, row, name: "realCompletionDate" }), // optional
+   signers: row.signers.map((s) => {
+      return {
+        id: s.id,
+        email: s.email,
+        name: s.name,
+        role: s.role,
+        signedAt: s.signedAt,
+      };
+   }),
  };
}
...
Enter fullscreen mode Exit fullscreen mode

This new property (signers) needs to be set on our components <ContractSignersList items={data.item.signers} /> in “ContractRoutes.Edit.View.tsx” and <ContractSignersForm items={item?.signers} /> in “ContractForm.tsx”.

ContractsSignersList

Up to this point, I’ve modified 9 autogenerated files, and 2 existing ones (the prisma schema and the RowWithDetails interface) to add signers functionality. And you can test it here.

git changes

If you’re a SaasRock Enterprise subscriber, you can download this progress here: github.com/AlexandroMtzG/saasrock-delega/releases/tag/part-2.

5. Implementing the Dropbox Sign API

I’m going to use the Dropbox Sign (formerly HelloSign) Node.js SDK.

I’ve already implemented the API at tools.saasrock.com (ask for access to the repo if you’re a SaasRock subscriber) so I don’t waste your time explaining custom implementations, you only need to know that in order to create signable contracts, you need to specify:

a Title — The title of the contract or Subject of the sent email
a Message — Contract details so signers know what they’ll sign
a list of Signers — A list of email addresses and names
and the Files — A list of contracts to be signed

Check out a quick demo here: https://www.loom.com/share/1fde3e46457d41abbabca60cf515c757

I’m going to create a file named “DropboxSignService.ts” inside my module folder (in my case app/modules/codeGeneratorTests/contracts/services), and paste the content of this public gist: gist.github.com/AlexandroMtzG/c934727cbd3d214c7ac8991b2ae5c409.

Now I need to install the SDK:

npm install hellosign-sdk hellosign-embedded
npm install -D @types/hellosign-sdk @types/hellosign-embedded
Enter fullscreen mode Exit fullscreen mode

And set two new required .env variables:

DROPBOX_SIGN_APP_ID="..."
DROPBOX_SIGN_API_KEY="..."
Enter fullscreen mode Exit fullscreen mode

Let’s think about the new requirements:

  • When a contract is created successfully in “ContractsService.create()”, I need to call the “DropboxSignService.create()”.
  • If the API call is successful, I need to store the “signature_request_id” value that it returns as a contract value. So we need to create a hidden Entity Property called “signatureRequestId”.
  • In the “ContractRoutes.Edit.Api.tsx” file, I need to get the signable document using the “DropboxSignService.get(item.signatureRequestId)” function and see if the current user is a signer, and if they are, get the sign URL with “DropboxSignService.getSignUrl(signer.signature_id)”.
  • If the current user is a signer and has not signed, render the Dropbox Sign Widget with the embedded URL.
  • I need a new custom action at “ContractRoutes.Edit.Api” that updates the signedAt date property when the user successfully signs in the rendered widget.

Creating the “signatureRequestId” Hidden Property

I’m going to visit “/admin/entities/contracts/properties/new” and create a property “signatureRequestId” of type TEXT, which is not required and hidden.

New property

Then, I’m going to add the property to my “ContractDto” interface:

export type ContractCreateDto = {
  ...
  signers: { email: string; name: string; role: string; }[]
+ signatureRequestId: string | undefined;
};
Enter fullscreen mode Exit fullscreen mode

Map the new value inside the “ContractHelpers.rowToDto” function:

function rowToDto({ entity, row }: { entity: EntityWithDetails; row: RowWithDetails }): ContractDto {
  return {
    ...
+   signatureRequestId: RowValueHelper.getText({ entity, row, name: "signatureRequestId" }) ?? "",
  };
}
Enter fullscreen mode Exit fullscreen mode

Creating a Dropbox Sign Document

Before creating the row itself, I’ll create the signable document to get the signatureRequestId value because I don’t want contracts that could not be created using the Dropbox Sign API. And in order to create one, first I have to save the PDF locally using the base64 “data.document” content.

...
+ import DropboxSignService from "./DropboxSignService";
+ import fs from "fs";

export namespace ContractService {
  ...
  export async function create(data: ContractCreateDto, session: { tenantId: string | null; userId?: string }): Promise<ContractDto> {
+   const randomId = Math.random().toString(36).substring(2, 15);
+   const fileDirectory = "/tmp/pdfs/files";
+   const filePath = `${fileDirectory}/${randomId}.pdf`;
+   if (!fs.existsSync(fileDirectory)) {
+     fs.mkdirSync(fileDirectory, { recursive: true });
+   }
+   fs.writeFileSync(filePath, data.document.file.replace(/^data:application\/pdf;base64,/, ""), "base64");
+   const document = await DropboxSignService.create({
+      embedded: true,
+      subject: data.name,
+      message: data.description ?? "",
+      signers: data.signers
+        .filter((f) => f.role === "signer")
+        .map((signer) => {
+          return { email_address: signer.email, name: signer.name };
+        }),
+      files: [filePath],
+   });
+   fs.unlinkSync(filePath);
    ...
    const rowValues = RowHelper.getRowPropertiesFromForm({
      entity,
      values: [
        ...
+       { name: "signatureRequestId", value: document.signature_request_id },
      ],
    });
    ...
    return ContractHelpers.rowToDto({ entity, row: item });
  }
}
Enter fullscreen mode Exit fullscreen mode

Getting the Embedded Sign URL

In the Loader function of the “ContractRoutes.Edit.Api.tsx” file, I’ll add find the embedded sign URL for the current user (if it’s a signer):

...
+ import DropboxSignService, { DropboxSignatureRequestDto } from "../../services/DropboxSignService";

export namespace ContractRoutesEditApi {
  export type LoaderData = {
    ...
+   signableDocument: {
+     clientId: string;
+     embeddedSignUrl?: string;
+     item?: DropboxSignatureRequestDto;
+   };
  };
  ...
  export let loader: LoaderFunction = async ({ request, params }) => {
    ...
+   let embeddedSignUrl = "";
+   if (item.signatureRequestId) {
+     const dropboxDocument = await DropboxSignService.get(item.signatureRequestId);
+     const currentUser = await getUser(userId);
+     const signer = dropboxDocument.signatures.find((x) => x.signer_email_address === currentUser!.email);
+     const contractSigner = item.signers.find((f) => f.email === currentUser!.email);
+     if (signer && !contractSigner?.signedAt) {
+       embeddedSignUrl = await DropboxSignService.getSignUrl(signer.signature_id);
+     }
+   }
    const data: LoaderData = {
      ...
+     embeddedSignUrl,
    };
    return json(data);
  };
...
Enter fullscreen mode Exit fullscreen mode

Rendering the Sign Widget

Now, if “signableDocument” is not undefined, that means we have a signer viewing the contract, so I need to add a “Sign” button that triggers the Dropbox Sign widget in the “ContractRoutes.Edit.View.tsx” file:

import ButtonPrimary from "~/components/ui/buttons/ButtonPrimary";

export default function ContractRoutesEditView() {
  ...
+  function onSign() {
+    // @ts-ignore
+    import("hellosign-embedded")
+      .then(({ default: HelloSign }) => {
+        return new HelloSign({
+          allowCancel: false,
+          clientId: data.signableDocument?.clientId,
+          skipDomainVerification: true,
+          testMode: true,
+        });
+      })
+      .then((client) => {
+        client.open(data.signableDocument?.embeddedSignUrl ?? "");
+        client.on("sign", () => {
+          alert("The document has been signed");
+        });
+      });
+  }
  return (
    <EditPageLayout...>
      <div className="relative items-center justify-between space-y-2 border-b border-gray-200 pb-4 sm:flex sm:space-y-0">
        ...
        <div className="flex space-x-2">
          ...
          {canUpdate() && (
            <ButtonSecondary onClick={() => { ... }}>
              <PencilIcon className="h-4 w-4 text-gray-500" />
            </ButtonSecondary>
          )}
+           {data.signableDocument && (
+            <ButtonPrimary onClick={onSign} className="bg-teal-600 py-1.5 text-white hover:bg-teal-700">
+              Sign
+            </ButtonPrimary>
          )}
          ...
Enter fullscreen mode Exit fullscreen mode

Sign button

Up to this point, you can see how it’s working now: https://www.loom.com/share/03f55a6b189b48218a8fee556a1e5660

Updating the Signers “signedAt” date property

Dropbox Sign is working correctly now, but I need to update in my database the “signer.signedAt” field so my “ContractSignersList” component renders accordingly.

First, I’ll submit an action “signed” when the widget tells me it has been signed at “ContractRoutes.Edit.View”:

...
export default function ContractRoutesEditView() {
  ...
  function onSign() {
    // @ts-ignore
    import("hellosign-embedded")
      .then(({ default: HelloSign }) => { ... })
      .then((client) => {
        client.open(data.signableDocument?.embeddedSignUrl ?? "");
        client.on("sign", () => {
+          const form = new FormData();
+          form.set("action", "signed");
+          submit(form, {
+            method: "post",
+          });
        });
      });
  }
  ...
Enter fullscreen mode Exit fullscreen mode

And add this new action in the “ContractRoutes.Edit.Api” Action function:

...
+ import { db } from "~/utils/db.server";

export namespace ContractRoutesEditApi {
  ...
  export const action: ActionFunction = async ({ request, params }) => {
  ...
+  } else if (action === "signed") {
+       const item = await ContractService.get(params.id!, {
+        tenantId,
+        userId,
+      });
+      const signer = item?.signers.find((f) => f.email === user?.email);
+      if (!signer) {
+        return json({ error: t("shared.unauthorized") }, { status: 400 });
+      } else if (signer.signedAt) {
+        return json({ error: "Already signed" }, { status: 400 });
+      }
+      await db.signer.update({
+        where: { id: signer.id },
+        data: { signedAt: new Date() },
+      });
+      return json({ success: t("shared.updated") });
+    }
    ...
  }
}
Enter fullscreen mode Exit fullscreen mode

And there you go:

End result


End Result

You can test the Contracts simple module at delega.saasrock.com/admin/entities/code-generator/tests/contracts.

And if you’re a SaasRock Enterprise subscriber, you can download this code in this release: github.com/AlexandroMtzG/saasrock-delega/releases/tag/part-2-dropbox-sign.

My contract details

What’s next?

In chapter 3, I’ll improve some functionality for the Contracts module:

  • Explaining “Linked Accounts” (basically 2 accounts that share stuff).
  • Add a “LinkedAccount” selector in the “ContractsForm” component, to restrict adding signers and viewers to the current account users and/or the selected linked account users.
  • Upon creation, share the contract with the signers, using the “RowPermissionsApi.shareWithUser(rowId, userId, accessLevel)” function.
  • Allow signing only in the “Pending” state, and once every signer has signed, move the contract to the “Signed” state and update the “documentSigned” property.

And many more improvements…

You can now get an idea of how quick and easy is to build SaaS applications with SaasRock 😀.

Follow me & SaasRock or subscribe to my newsletter to stay tuned!

Top comments (0)