与Saasrock建立合同SaaS-第3部分 - 与链接帐户共享合同
#javascript #网络开发人员 #教程 #saas

在本章中,我将让我的用户与其他帐户链接,以共享合同,并且我将建立一个更好的签名者选择器组件。

查看part 2 here

内容

  1. 什么是SaasRock 链接帐户
  2. 添加 LinkedAccount 选择器
  3. 自动与选定的签名者共享
  4. 改善合同的工作流程

Cover


将合同模块移至 /app /:租户

记得第2章中的6个生成路由(索引,新,编辑,活动,共享和标签)?我在文件夹 “ app/routes/admin/entities/code-generator/tests/Contracts”中生成了它们” 。仅在 /管理仪表板上工作,合同模块应适用于申请租户。< /p>

我将复制并粘贴该文件夹到 “ app/routes/app。$ tent。在我们的路线上 “/app/acme-corp-1/合同” (acme-corp-1是种子租户)。这将覆盖我们不再需要的默认自动化的无代码路由。

Contracts Module at /app/:tenant/contracts

git changes

1.什么是链接帐户?

SaasRock中,我创建了一个名为链接帐户的核心概念。基本上是2个同意链接的帐户,因此他们从彼此分享的东西中受益。在这种特定用例中,他们将共享合同和文件。

创建2个新用户/帐户

我将注册2个新用户,这将创建其相应的帐户:

Registering

顺便说一句,默认注册表需要电子邮件 password 。在这种情况下,我需要公司和用户的名称,因此我在 “ app/utils/db/appconfiguration.db.server.ts”上更新了我的应用程序配置。 br>

...
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,
      ...

现在,如果比尔想告诉蒂姆连接他们的帐户,他将转到 “/app/microsoft-corporation/settings/linked-accounts/new” ,查找蒂姆的帐户:

Finding Tim's accounts

然后,将邀请发送到链接 Microsoft Corporation Apple Inc

Link with another account

现在,蒂姆可以接受拒绝 bill的邀请(我会接受)

Pending invitations

链接帐户的目的

好吧,我已经链接了2个帐户,但是现在呢?链接苹果和微软链接的重点是彼此共享。

例如,Bill将创建名为“ iPhone转换为Windows Phone OS”的合同。

Contract

但是,除非单击“共享”按钮,否则Tim将无法访问,并与Apple Inc.的用户共享。

Sharing Contract

现在,蒂姆可以在 “/app/apple-inc/Contracts”中查看合同”

Contracts List Route

甚至是因为比尔将他添加为签名者。

Tim's Signature

如果Bill刚刚在下拉菜单中选择了Tim的电子邮件,而不是手动键入它,那不是很好吗?

2.添加linkedAccount选择器

现在“ Contractsignersform” 用于手动添加签名者或观众:

ContractSignersForm

我不再想要这个,我希望我的用户不输入任何内容,因此每个签名者行都需要一个租户/帐户(要与该帐户共享合同)和其相应的用户(让他们签名)

让我们从修改架构开始:

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?
}

如果我运行npx prisma migrate dev --name signers_with_tenant_and_user,它将创建我的新迁移文件,但是它会警告我数据库将被重置,但我不想要它,所以在应用它之前,我要在我的本地上进行sql DELETE FROM "Row";命令数据库是因为新模型不兼容。但是,如果您仍然无法应用它,只需运行npx prisma db push,尽管这不是理想的,尤其是在开发模式下(但我不想再次创建实体,注册2个用户,链接他们的帐户并丢失更多数据)

从现在开始,我的签名模型已更改并具有关系(租户和用户),将其包装到自己的“ signerdto.ts” 文件< strong>“ ContractDto.ts” :

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

这需要对新接口/类型进行大量的文件修改:

// 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 } } },
};

以及使用新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,

“ ContractsSignerSform” “ ContractsSignerSlist” 组件现在应该使用新的“ Signerdto” 接口,既可以显示和提交签名者。但是首先,该表格需要服务器中的一些数据:

// 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,
+     }),
    };

“ Contractform” 组件需要此新的possibleSigners Prop。顺便说一句,我将“ Contractsignersform”移到了顶部,并且仅显示isCreating为true (创建合同)

...
+ 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>
      ...

最终结果:所有当前帐户用户以及链接帐户中的所有用户的列表。在这种情况下,我们将Tim和Bill在同一下拉列表中:

Linked Account Users Selector

这些都是我所有的git更改:

git changes

到目前

这是ContractSignersForm的公共要旨,这是ContractSignersList的公共要点。他们使用我制作的一些自定义组件,例如Collapsiblerow,但我相信它们在我的RemixBlocks开源项目中。

3.自动与选定的签名人共享

现在,我们可以使用 “ permissionsapi.sharewithtenant()” 成功创建合同后。

...
+ 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({
    ...

以这种方式,每当有人创建合同时,它将自动为每个帐户设置正确的权限。您可以在此处查看快速视频演示:https://www.loom.com/share/cbff06f836bf4cb8b0d753c7bf516b58

4.改善合同的工作流程

在进入下一个功能之前,我想修复一些事情:

  • 合同的所有者可以 “提交” 合同,将其发送到 “待处理” 状态。
  • 允许仅在 “待处理” 状态中签名。
  • 每个签名者签署后,将合同移至 “签名” 状态并更新documentSigned属性PDF文档。

合同工作流程

每份合同的可能状态是:

  • 草稿 - 默认状态
  • 等待 - 已提交草案
  • 签名 - 每个签名者都签名
  • 存档 - 只是为了减少噪音

注意我只允许在草案中允许更新和删除操作:

Contract Workflow States

合同工作流程步骤/过渡/行动

我仍然不确定状态之间过渡的最佳词是什么,但让我们称它们为steps

Contract Workflow Steps

我可能会有更多步骤,例如 “ fist” (从待定到草稿) ”提醒“ (保持待处理但发送提醒电子邮件),但这取决于您的用例。

从草稿到等待

什么时候可以提交草稿?好吧,当合同创建者/所有者决定时!因此,在此步骤中没有实际的验证,但是要牢记一些事情:签名者不应在草案中签名:

// 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>
          )}
        ...

因此,我们可以看到标志按钮,但禁用:

Disabled Sign Button

从未决到签名

您可能已经注意到,没有实际的 “ sign” 步骤将状态从登录到 >。那是因为在收集所有签名后,我需要手动处理。

新的 “ ContractService.CheckifSigned()”功能是将所有内容连接的!

  • 合同的状态不在审理中? 跳过,因为它只能检查待处理合同
  • 计算所有等待签名。如果有的话,跳过,因为合同还没有准备好。
  • 从Dropbox符号API获取文档,看看它是否已完成(收集的所有签名)。做几次,每次尝试之间等待2秒,因为它们不会立即处理最终副本。
  • 如果合同进行了几次尝试后未完成, skip
  • 从Dropbox Sign的下载API函数中获取最终副本。
  • 更新合同的属性documentSigned
  • 将状态设置为 “签名”

代码:

...

// 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",
    });
  }
}

我将在 “ contactroutes.edit.api” loader上使用它,因此它在每个签名后检查一下,或者每个页面加载(当时合同处于待处理状态)

// 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);
       ...
    }

,顺便说一句,由于这可能是由不是所有者的另一个用户触发的(并且没有edit访问),我需要更新 ContractsService.update() session参数,因此可以是undefined。否则,它将验证当前用户的访问级别。因此,我将使它无法定义

...
// 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> {
  ...

最终结果

如果您是Saasrock Enterprise订户,则可以在此版本中下载此代码:github.com/AlexandroMtzG/saasrock-delega/releases/tag/part-3-contracts-workflow-and-setting-final-signed-copy

查看视频演示:https://www.loom.com/share/2824fa7246f846b1a3343822c0d2d708


下一步是什么?

在第4章中,我将开始研究文档模块:

  • 建模实体
  • 自动化Crud的文件
  • 将PDF转换为图像
  • Tesseract.js的OCR扫描
  • 链接帐户的日历视图(我的提供商)文档

关注meSaasRock或订阅my newsletter以保持关注!