DEV Community

Cover image for Building a Contracts SaaS with SaasRock — Part 3 — Sharing Contract with Linked Account
Alexandro Martinez
Alexandro Martinez

Posted on • Updated on

Building a Contracts SaaS with SaasRock — Part 3 — Sharing Contract with Linked Account

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.

Chapter 3

  1. What are SaasRock Linked Accounts?
  2. Adding a LinkedAccount Selector
  3. Automatically Share with Selected Signers
  4. Improving the Contract's Workflow

Cover


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

git changes

1. What are Linked Accounts?

In SaasRock, 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:

Registering

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":

...
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,
      ...
Enter fullscreen mode Exit fullscreen mode

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

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

Link with another account

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

Pending invitations

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

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

Sharing Contract

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

Contracts List Route

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

Tim's Signature

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

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:

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?
}
Enter fullscreen mode Exit fullscreen mode

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":

+ export type SignerDto = {
+  id?: string;
+  tenant: { id: string; name: string };
+  user: { id: string; email: string; name: string };
+  role: string;
+  signedAt: Date | null;
+};
Enter fullscreen mode Exit fullscreen mode

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

// 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 } } },
};
Enter fullscreen mode Exit fullscreen mode

And in the backend functionality using the new Dto:

// 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,
Enter fullscreen mode Exit fullscreen mode

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:

// 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,
+     }),
    };
Enter fullscreen mode Exit fullscreen mode

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):

...
+ 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>
      ...
Enter fullscreen mode Exit fullscreen mode

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

These are all my git changes:

git changes

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.

Here's the public gist for the ContractSignersForm, and here's the public gist for ContractSignersList. They use some custom components I've made, such as CollapsibleRow, but I believe they're in my RemixBlocks open-source project.

3. Automatically Share with Selected Signers

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

...
+ 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({
    ...
Enter fullscreen mode Exit fullscreen mode

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

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

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:

// 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>
          )}
        ...
Enter fullscreen mode Exit fullscreen mode

So we can see the Sign button, but disabled:

Disabled Sign Button

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:

...

// 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",
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

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):

// 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);
       ...
    }
Enter fullscreen mode Exit fullscreen mode

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:

...
// 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> {
  ...
Enter fullscreen mode Exit fullscreen mode

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.

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
  • Calendar view for Linked Accounts (my providers) documents

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

Top comments (0)