- Published on
Building a Contracts SaaS with SaasRock — Part 3 — Sharing Contract with Linked Account
- Authors
- Name
- Alexandro Martinez
- @alexandromtzg
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
- What are SaasRock Linked Accounts?
- Adding a LinkedAccount Selector
- Automatically Share with Selected Signers
- Improving the Contract's Workflow
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.
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:
- Tim: alex.martinez+tim@absys.com.mx - Apple Inc.
- Bill: alex.martinez+bill@absys.com.mx - Microsoft Corporation.
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,
...
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:
Then, send the invitation to link Microsoft Corporation with Apple Inc.
Now, Tim can either Accept or Reject Bill's invitation (I'll accept):
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".
But Tim will not have access unless Bill clicks on the share button, and share it with Apple Inc.'s users.
Now Tim can view the contract at "/app/apple-inc/contracts":
…and even sign it because Bill added him as a Signer.
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:
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?
}
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;
+};
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 } } },
};
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,
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,
+ }),
};
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>
...
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:
These are all my 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({
...
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 Steps/Transitions/Actions
I'm still not sure what's the best word for a transition between states, but let's call them 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>
)}
...
So we can see the Sign button, but disabled:
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",
});
}
}
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);
...
}
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> {
...
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!
If you liked this post, follow me on Twitter or subscribe to the Newsletter for more 😃