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.
Chapter 2
- Modeling the Entity
- Autogenerating the Files for CRUD
- Creating the Signers Components
- Implementing the Signers Model
- Implementing the Dropbox Sign API
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”:
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).
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):
With this model, I now have a full no-code CRUD functionality for contracts, check out the quick video demo here:
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:
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:
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.
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):
I’ll add this component at the bottom of the “ContractForm.tsx”:
Then, for read-only purposes a “ContractSignersList.tsx” component:
And this component will go in the “ContractRoutesEditView.tsx” autogenerated view component:
And this component will render like in the following image:
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?
+}
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[]
}
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; }[]
};
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(
...
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 });
}
...
}
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; }[]
};
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,
};
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,
+ };
+ }),
};
}
...
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”.
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.
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:
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
And set two new required .env variables:
DROPBOX_SIGN_APP_ID="..."
DROPBOX_SIGN_API_KEY="..."
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.
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;
};
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" }) ?? "",
};
}
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 });
}
}
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);
};
...
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>
)}
...
Up to this point, you can see how it’s working now:
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",
+ });
});
});
}
...
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") });
+ }
...
}
}
And there you go:
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.
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!