Building a Contracts SaaS with SaasRock — Part 3 — Sharing Contract with Linked Account
Tags: webdev, javascript, tutorial, saas
Published: 2023-01-31 09:00:00

---
title: Building a Contracts SaaS with SaasRock — Part 3 — Sharing Contract with Linked Account
published: true
description: In this chapter, I'll let my users link with other accounts in order to share Contracts between them, and I'll build a better Signer selector component.
tags: webdev,javascript,tutorial,saas
cover_image: https://cdn-images-1.medium.com/max/1600/1*pIpsqJCawBTX38x-P1ogYQ.png
# Use a ratio of 100:42 for best results.
published_at: 2023-01-31 09:00 +0000
---

In this chapter, I'll let my users link with other accounts in order to share **Contracts** between them, and I'll build a better **Signer** selector component.

Check out [part 2 here](https://dev.to/alexandromtzg/building-a-contracts-saas-with-saasrock-part-2-signing-contracts-with-dropbox-sign-4bm8).

## Chapter 3

1. What are [SaasRock](https://saasrock.com/?ref=hashnode&utm_content=saasrock-delega-part-3) **Linked Accounts**?
2. Adding a **LinkedAccount** Selector
3. Automatically Share with Selected **Signers**
4. Improving the Contract's **Workflow**

![Cover](https://cdn-images-1.medium.com/max/1600/1*pIpsqJCawBTX38x-P1ogYQ.png)

---

### Moving the Contracts module to /app/:tenant

Remember the 6 generated routes in chapter 2 _(Index, New, Edit, Activity, Share, and Tags)_? I generated them inside the folder _*"app/routes/admin/entities/code-generator/tests/contracts"*_. That only works on the /admin dashboard, and the Contract modules should be for the application tenants.

I'm going to copy and paste that folder into the _*"app/routes/app.$tenant"*_ one, restart the application, and the module should work at our route _*"/app/acme-corp-1/contracts"*_ _(acme-corp-1 being a seeded tenant)_. This will override the default autogenerated no-code routes that we don't need anymore.

![Contracts Module at /app/:tenant/contracts](https://cdn-images-1.medium.com/max/1600/1*UiPbziJ-t_l9TDdGY3S0jQ.png)

![git changes](https://cdn-images-1.medium.com/max/1600/1*sijiWRsinaIeErLxUuPrWQ.png)

## 1. What are Linked Accounts?

In [SaasRock](https://saasrock.com/?ref=hashnode&utm_content=saasrock-delega-part-3), I created a Core concept called linked accounts. It's basically 2 accounts agreeing to link so they benefit from sharing things with each other. In this specific use case, they'd be sharing contracts and documents.

### Creating 2 new users/accounts

I'm going to register 2 new users, which will create their corresponding accounts:

- **Tim**: alex.martinez+tim@absys.com.mx - Apple Inc.
- **Bill**: alex.martinez+bill@absys.com.mx - Microsoft Corporation.

![Registering](https://cdn-images-1.medium.com/max/1600/1*Y9GY6fXgNpVzvO4xvZAf0Q.png)

By the way, the default registration form requires _email_ and _password_ only. In this case, I require the company's and user's name, so I updated my app configuration at _*"app/utils/db/appConfiguration.db.server.ts"*_:

```diff
...
export async function getAppConfiguration(): Promise<AppConfiguration> {
  const conf: AppConfiguration = {
    ...
    auth: {
-     requireEmailVerification: process.env.AUTH_REQUIRE_VERIFICATION === "true",
-     requireOrganization: process.env.AUTH_REQUIRE_ORGANIZATION === "true",
-     requireName: process.env.AUTH_REQUIRE_NAME === "true",
+     requireEmailVerification: false, // your decision
+     requireOrganization: true,
+     requireName: true,
      ...
```

Now, if Bill wants to tell Tim to connect their accounts, he'd go to _*"/app/microsoft-corporation/settings/linked-accounts/new"*_, and find Tim's accounts:

![Finding Tim's accounts](https://cdn-images-1.medium.com/max/1600/1*gzQWOTLHd__ktS9k8cmFmg.png)

Then, send the invitation to link **Microsoft Corporation** with **Apple Inc**.

![Link with another account](https://cdn-images-1.medium.com/max/1600/1*DDGRukpwXcopq4cavhn7mg.png)

Now, Tim can either **Accept** or **Reject** Bill's invitation _(I'll accept)_:

![Pending invitations](https://cdn-images-1.medium.com/max/1600/1*ujL5f1kQuJMHFoUtbyP3ag.png)

### The purpose of Linked Accounts

Ok, I have linked 2 accounts, but what now? The whole point of having Apple and Microsoft linked is to share stuff with each other.

For example, Bill will create a contract named "iPhone switching to Windows Phone OS".

![Contract](https://cdn-images-1.medium.com/max/1600/1*eeVLMlXvY0_xz8AvBO7LFQ.png)

But Tim will not have access unless Bill clicks on the share button, and share it with Apple Inc.'s users.

![Sharing Contract](https://cdn-images-1.medium.com/max/1600/1*orE6Cj4xjcm6qcxz8Ggc0w.png)

Now Tim can view the contract at _*"/app/apple-inc/contracts"*_:

![Contracts List Route](https://cdn-images-1.medium.com/max/1600/1*DsqSU4jtRv-TbLo8N-Lyeg.png)

…and even sign it because Bill added him as a Signer.

![Tim's Signature](https://cdn-images-1.medium.com/max/1600/1*wS1F65gL5QAFoCbUaqvL4w.png)

Wouldn't it be nice if Bill had just selected Tim's email in a dropdown menu, instead of typing it manually?

## 2. Adding a LinkedAccount Selector

Right now the **"ContractSignersForm"** is for manually adding signers or viewers:

![ContractSignersForm](https://cdn-images-1.medium.com/max/1600/1*JqES8h2rq0cf9pFyD-StXA.png)

I don't want this anymore, I'd like my users to not type anything, so every Signer row needs to have a tenant/account _(to share the contract with that account)_ and its corresponding user _(to let them sign)_.

Let's start by modifying the schema:

```diff
model User {
  ...
+ signers Signer[]
}

model Tenant {
  ...
+ signers Signer[]
}

model Signer {
  id       String    @id @default(cuid())
  rowId    String
  row      Row       @relation(fields: [rowId], references: [id], onDelete: Cascade)
- email    String
- name     String
+ tenantId String
+ tenant   Tenant    @relation(fields: [tenantId], references: [id], onDelete: Cascade)
+ userId   String
+ user     User      @relation(fields: [userId], references: [id], onDelete: Cascade)
  role     String
  signedAt DateTime?
}
```

If I run `npx prisma migrate dev --name signers_with_tenant_and_user` it will create my new migration file, but it will warn me that the database will be reset, but I don't want that, so before applying it I'm  doing an SQL `DELETE FROM "Row";` command on my local database because the new model is incompatible. But if you still can't apply it, just run `npx prisma db push` although this is not ideal, especially in development mode _(but I don't want to create the entity again, register the 2 users, link their accounts, and lose more data)_.

Since now my Signer model changed and has relationships _(tenant and user)_, it makes sense to wrap it into its own **"SignerDto.ts"** file right beside **"ContractDto.ts"**:

```diff
+ export type SignerDto = {
+  id?: string;
+  tenant: { id: string; name: string };
+  user: { id: string; email: string; name: string };
+  role: string;
+  signedAt: Date | null;
+};
```

This requires quite a bit of file modifications for the new interface/type:

```diff
// ContractDto.ts
export type ContractDto = {
  ...
- signers: { id: string; email: string; name: string; role: string; signedAt: Date | null }[];
+ signers: SignerDto[];
  ...
};

// ContractCreateDto.ts
export type ContractCreateDto = {
  ...
- signers: { email: string; name: string; role: string }[];
+ signers: SignerDto[];
};

// ContractHelpers.ts
function rowToDto({ entity, row }: { entity: EntityWithDetails; row: RowWithDetails }): ContractDto {
  return {
    ...
    signers: row.signers.map((s) => {
      return {
        id: s.id,
-       email: s.email,
-       name: s.name,
+       tenant: { id: s.tenantId, name: s.tenant.name },
+       user: { id: s.userId, email: s.user.email, name: `${s.user.firstName} ${s.user.lastName}` },
        role: s.role,
        signedAt: s.signedAt,
      };
    }),
    ...
  }
}

// rows.db.server.ts
export type RowWithDetails = Row & {
  ...
- signers: Signer[];
+ signers: (Signer & { tenant: Tenant; user: UserSimple })[];
};

// rows.db.server.ts
export const includeRowDetails = {
  ...
- signers: true,
+ signers: { include: { tenant: true, user: { select: UserUtils.selectSimpleUserProperties } } },
};
```

And in the backend functionality using the new Dto:

```diff
// ContractRoutes.Edit.Api.ts
export namespace ContractRoutesEditApi {
  ...
  export let loader: LoaderFunction = async ({ request, params }) => {
    if (item.signatureRequestId) {
      ...
-     const contractSigner = item.signers.find((f) => f.email === currentUser!.email);
+     const contractSigner = item.signers.find((f) => f.user.id === currentUser?.id);
      ...
    }
  ...
  export const action: ActionFunction = async ({ request, params }) => {
    ...
    } else if (action === "signed") {
      ...
-     const signer = item?.signers.find((f) => f.email === user?.email);
+     const signer = item?.signers.find((f) => f.user.id === user?.id);
    ...

// ContractRoutes.New.Api.ts
...
+ import { SignerDto } from "../../dtos/SignerDto";
export namespace ContractRoutesNewApi {
  ...
  export const action: ActionFunction = async ({ request, params }) => {
    ...
    if (action === "create") {
      try {
        ...
-         const signers: { email: string; name: string; role: string }[] = form.getAll("signers[]").map((f: FormDataEntryValue) => {
-           return JSON.parse(f.toString());
-         });
+         const signers: SignerDto[] = form.getAll("signers[]").map((f: FormDataEntryValue) => {
+           return JSON.parse(f.toString());
+         });
        ...
-       const invalidSigners = signers.filter((f) => f.email === "" || f.name === "" || f.role === "");
+       const invalidSigners = signers.filter((f) => !f.tenant.id || !f.user.email || !f.user.name || f.role === "");
        ...

// ContractService.ts
...
export namespace ContractService {
  export async function create(data: ContractCreateDto, session: { tenantId: string | null; userId?: string }): Promise<ContractDto> {
    ...
-        return { email_address: signer.email, name: signer.name };
+        return { email_address: signer.user.email, name: signer.user.name }; 
    ...
-        email: signer.email,
-        name: signer.name,
+        tenantId: signer.tenant.id,
+        userId: signer.user.id,
```

The **"ContractsSignersForm"** and **"ContractsSignersList"** components should now use the new **"SignerDto"** interface, both for displaying and submitting the signers. But first, the form needs some data from the server:

```diff
// ContractRoutes.New.Api.ts
...
+ import { TenantUserWithDetails } from "~/utils/db/tenants.db.server";
+ import { LinkedAccountsApi } from "~/utils/api/LinkedAccountsApi";
export namespace ContractRoutesNewApi {
  export type LoaderData = {
    ...
+   possibleSigners: TenantUserWithDetails[];
  }
  export let loader: LoaderFunction = async ({ request, params }) => {
    ...
    const data: LoaderData = {
      ...
+     possibleSigners: await LinkedAccountsApi.getAllUsers(tenantId ?? "", {
+       includeCurrentTenant: true,
+     }),
    };
```

And the **"ContractForm"** component needs this new `possibleSigners` prop. By the way, I moved **"ContractSignersForm"** to the top, and only shows if `isCreating` is true _(creating contract)_:

```diff
...
+ import { TenantUserWithDetails } from "~/utils/db/tenants.db.server";

export default function ContractForm({...
+ possibleSigners,
}: { ...
+ possibleSigners?: TenantUserWithDetails[];
}) {
  return (
    <Form key={!isDisabled() ? "enabled" : "disabled"} method="post" className="space-y-4">
      {item ? <input name="action" value="edit" hidden readOnly /> : <input name="action" value="create" hidden readOnly />}

+     {isCreating && (
+       <div>
+         <h3 className="pb-3 text-sm font-medium leading-3 text-gray-800">Signers</h3>
+         <ContractSignersForm possibleSigners={possibleSigners ?? []} />
+       </div>
+     )}

      <InputGroup title={t("shared.details")}>
        ...
      </InputGroup>
      
-     <div>
-       <h3 className="pb-3 text-sm font-medium leading-3 text-gray-800">Signers</h3>
-       <ContractSignersForm items={item?.signers} />
-     </div>
      ...
```

**End result**: A list of all the current account users plus all users from linked accounts. In this case, we have Tim and Bill in the same dropdown:

![Linked Account Users Selector](https://cdn-images-1.medium.com/max/1600/1*cMn-QiR0mcOOY6c4iRDl4w.png)

These are all my git changes:

![git changes](https://cdn-images-1.medium.com/max/1600/1*QW-eCdvF0pzSexgwEr5-Bg.png)

Up to this point, if you're a SaasRock Enterprise subscriber, download this release here: [github.com/AlexandroMtzG/saasrock-delega/releases/tag/part-3-linked-account-signers](https://github.com/AlexandroMtzG/saasrock-delega/releases/tag/part-3-linked-account-signers).

Here's the public gist for the [ContractSignersForm](https://gist.github.com/AlexandroMtzG/af55cf065b187e6ae4657ccde32202ab), and here's the public gist for [ContractSignersList](https://gist.github.com/AlexandroMtzG/ad0da09e141b8f6bd8d1847524af40ae). They use some custom components I've made, such as CollapsibleRow, but I believe they're in my [RemixBlocks](https://remixblocks.com/) open-source project.

## 3. Automatically Share with Selected Signers

Now, we can use the _*"PermissionsApi.shareWithTenant()"*_ function after successfully creating a contract.

```diff
...
+ import { RowPermissionsApi } from "~/utils/api/RowPermissionsApi";

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.signers.map(async (signer) => {
+          await RowPermissionsApi.shareWithTenant(item.id, signer.tenant.id, "comment");
+          return await db.signer.create({
    ...
```

This way, every time someone creates a contract, it will automatically set the right permissions for each account. You can check out the quick video demo here: https://www.loom.com/share/cbff06f836bf4cb8b0d753c7bf516b58

## 4. Improving the Contract's Workflow

There are a few things I want to fix before moving into the next feature:

- The contract's owner can _*"Submit"*_ the contract, sending it to the _*"Pending"*_ state.
- Allow signing only in the _*"Pending"*_ state.
- Once every signer has signed, move the contract to the _*"Signed"*_ state and update the `documentSigned` property PDF document.

### Contract Workflow States

The possible states for every Contract, are:

- **Draft** - Default state
- **Pending** - A draft has been submitted
- **Signed** - Every signer has signed
- **Archived** - Just to reduce the noise

Notice how I only allow update and delete actions in the Draft state:

![Contract Workflow States](https://cdn-images-1.medium.com/max/1600/1*fjTomSGtTXIo6jg5ofB6NA.png)

### Contract Workflow Steps/Transitions/Actions

I'm still not sure what's the best word for a transition between states, but let's call them `steps`.

![Contract Workflow Steps](https://cdn-images-1.medium.com/max/1600/1*GQ-tnvYj2CXKN98lokLkaA.png)

I could have many more steps, such as _*"Withdraw"*_ _(from pending to draft)_ or _*"Remind"*_ _(keep it pending but send a reminder email)_, but this depends on your use case.

### From Draft to Pending

When can a draft be submitted? Well, when the Contract creator/owner decides! So there's no actual validation in this step, but there is something to keep in mind: Signers should not be able to sign in the Draft state:

```diff
// ContractRoutes.Edit.View.tsx
...
export default function ContractRoutesEditView() {
  ...
  return (
      ...
          {data.signableDocument && (
-             <ButtonPrimary onClick={onSign} className="bg-teal-600 py-1.5 text-white hover:bg-teal-700">
+             <ButtonPrimary
+               disabled={data.item.row.workflowState?.name !== "pending"}
+               onClick={onSign}
+               className="bg-teal-600 py-1.5 text-white hover:bg-teal-700">
              Sign
            </ButtonPrimary>
          )}
        ...
```

So we can see the Sign button, but disabled:

![Disabled Sign Button](https://cdn-images-1.medium.com/max/1600/1*x7LfBslNVK2j3sJgmrxfZw.png)

### From Pending to Signed

You may have noticed that there's no actual _*"Sign"*_ step that moves the state from _Pending_ to _Signed_. That's because that's something I need to handle manually after all the signatures have been collected.

A new _*"ContractService.checkIfSigned()*"_ function is required, which will connect everything!

- Contract's state is not pending? **SKIP**, as it should only check for pending contracts
- Count all pending signatures. If there are any, **SKIP**, as the contract is not ready.
- Get the document from Dropbox Sign API and see if it has been completed _(all signatures collected)_. Do this a few times, and wait 2 seconds between each try, because they don't process the final copy instantly.
- If the Contract is not completed after a few tries, **SKIP**.
- Get the final copy from Dropbox Sign's download API function.
- Update the Contract's property `documentSigned`.
- Set the state to _*"Signed"*_.

Code:

```ts
...

// ContractService.ts
import { WorkflowsApi } from "~/utils/api/WorkflowsApi";
export namespace ContractService {
  ...
  export async function checkIfSigned(item: ContractDto) {
    if (item?.row.workflowState?.name !== "pending") {
      return;
    }
    const pendingSignatures = await db.signer.count({
      where: { rowId: item.row.id, role: "signer", signedAt: null },
    });
    if (pendingSignatures > 0) {
      return;
    }
    const tries = { current: 0, max: 10, secondsToWait: 2 };
    do {
      tries.current++;
      const document = await DropboxSignService.get(item!.signatureRequestId!);
      // eslint-disable-next-line no-console
      console.log(`[ContractService.checkIfSigned] Try ${tries.current}/${tries.max}. Document.is_complete: ${document.is_complete}`);
      if (document.is_complete) {
        break;
      }
      await new Promise((resolve) => setTimeout(resolve, tries.secondsToWait * 1000));
      if (tries.current >= tries.max) {
        return;
      }
    } while (true);
    const finalCopy = await DropboxSignService.download(item!.signatureRequestId!);
    await ContractService.update(
      item.row.id,
      {
        documentSigned: {
          type: finalCopy.type,
          name: finalCopy.name,
          title: finalCopy.name,
          file: `data:${finalCopy.type};base64,${finalCopy.base64}`,
        },
      },
      undefined
    );
    await WorkflowsApi.setState({
      entity: { name: "contract" },
      rowId: item.row.id,
      stateName: "signed",
    });
  }
}
```

And I'll  use this on the _*"ContractRoutes.Edit.Api"*_ loader so it checks after every signature, or every page load _(while the contract is in the pending state)_:

```diff
// ContractRoutes.Edit.Api.ts
export namespace ContractRoutesEditApi {
  ...
  export let loader: LoaderFunction = async ({ request, params }) => {
    if (item.signatureRequestId) {
      ...
      if (!item) {
        return json({ error: t("shared.notFound"), status: 404 });
      }
+     try {
+       await ContractService.checkIfSigned(item!);
+     } catch (error: any) {
+       // eslint-disable-next-line no-console
+       console.log(error.message);
+     }
       const permissions = await getUserRowPermission(item.row, tenantId, userId);
       ...
    }
```

And by the way, since this could be triggered by another user who is not the owner _(and doesn't have an `edit` access)_, I need to update the _*ContractsService.update()*_ `session` parameter so it can be `undefined`. Otherwise, it will validate the current user's access level. So I'll make it **undefinable**:

```diff
...
// ContractService.ts
export namespace ContractService {
  ...
- export async function update(id: string, data: Partial<ContractDto>, session: { tenantId: string | null; userId?: string }): Promise<ContractDto> {
+ export async function update(
+    id: string,
+    data: Partial<ContractDto>,
+    session: { tenantId: string | null; userId?: string } | undefined
  ): Promise<ContractDto> {
  ...
```

---

## End Result

If you're a SaasRock Enterprise subscriber, you can download this code in this release: [github.com/AlexandroMtzG/saasrock-delega/releases/tag/part-3-contracts-workflow-and-setting-final-signed-copy](https://github.com/AlexandroMtzG/saasrock-delega/releases/tag/part-3-contracts-workflow-and-setting-final-signed-copy).

Check out the video demo: https://www.loom.com/share/2824fa7246f846b1a3343822c0d2d708

---

## What's next?

In chapter 4, I'll start working on the **Documents** Module:

- Modeling the Entity
- Autogenerating the Files for CRUD
- Converting PDF to Image
- OCR scan with [Tesseract.js](https://github.com/naptha/tesseract.js/)
- Calendar view for Linked Accounts _(my providers)_ documents

Follow [me](https://twitter.com/AlexandroMtzG) & [SaasRock](https://twitter.com/saas_rock) or subscribe to [my newsletter](https://saasrock.com/newsletter) to stay tuned!