在本章中,我将让我的用户与其他帐户链接,以共享合同,并且我将建立一个更好的签名者选择器组件。
查看part 2 here。
内容
- 什么是SaasRock 链接帐户?
- 添加 LinkedAccount 选择器
- 自动与选定的签名者共享
- 改善合同的工作流程
将合同模块移至 /app /:租户
记得第2章中的6个生成路由(索引,新,编辑,活动,共享和标签)?我在文件夹 “ app/routes/admin/entities/code-generator/tests/Contracts”中生成了它们” 。仅在 /管理仪表板上工作,合同模块应适用于申请租户。< /p>
我将复制并粘贴该文件夹到 “ app/routes/app。$ tent。在我们的路线上 “/app/acme-corp-1/合同” (acme-corp-1是种子租户)。这将覆盖我们不再需要的默认自动化的无代码路由。
1.什么是链接帐户?
在SaasRock中,我创建了一个名为链接帐户的核心概念。基本上是2个同意链接的帐户,因此他们从彼此分享的东西中受益。在这种特定用例中,他们将共享合同和文件。
创建2个新用户/帐户
我将注册2个新用户,这将创建其相应的帐户:
- tim :alex.martinez+tim@absys.com.mx - 苹果公司
- Bill :alex.martinez+bill@absys.com.mx - Microsoft Corporation。
顺便说一句,默认注册表需要电子邮件和 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” ,查找蒂姆的帐户:
然后,将邀请发送到链接 Microsoft Corporation Apple Inc 。
现在,蒂姆可以接受或拒绝 bill的邀请(我会接受):
链接帐户的目的
好吧,我已经链接了2个帐户,但是现在呢?链接苹果和微软链接的重点是彼此共享。
例如,Bill将创建名为“ iPhone转换为Windows Phone OS”的合同。
但是,除非单击“共享”按钮,否则Tim将无法访问,并与Apple Inc.的用户共享。
现在,蒂姆可以在 “/app/apple-inc/Contracts”中查看合同” :
甚至是因为比尔将他添加为签名者。
如果Bill刚刚在下拉菜单中选择了Tim的电子邮件,而不是手动键入它,那不是很好吗?
2.添加linkedAccount选择器
现在“ 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在同一下拉列表中:
这些都是我所有的git更改:
到目前
这是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文档。
合同工作流程
每份合同的可能状态是:
- 草稿 - 默认状态
- 等待 - 已提交草案
- 签名 - 每个签名者都签名
- 存档 - 只是为了减少噪音
注意我只允许在草案中允许更新和删除操作:
合同工作流程步骤/过渡/行动
我仍然不确定状态之间过渡的最佳词是什么,但让我们称它们为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>
)}
...
因此,我们可以看到标志按钮,但禁用:
从未决到签名
您可能已经注意到,没有实际的 “ 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扫描
- 链接帐户的日历视图(我的提供商)文档
关注me和SaasRock或订阅my newsletter以保持关注!