DEV Community

Cover image for Building a Contracts SaaS with SaasRock — Part 4 — B2B Document Management Module
Alexandro Martinez
Alexandro Martinez

Posted on

Building a Contracts SaaS with SaasRock — Part 4 — B2B Document Management Module

After a 3-day break, I’ll create another entity called Documents so users can share documents between accounts, aka B2B document compliance management.

Check out part 3.

This uses the latest SaasRock updates, basically v0.8.2 plus a few fixes (February 6th, 2023).

Chapter 4

  1. Document Concepts
  2. Custom Account Documents Calendar View
  3. Creating the Document and Document Type Entities
  4. Scanning a Document with Tesseract.js
  5. Using the Custom Calendar View
  6. Viewing my Linked Accounts Documents

B2B Document Management Module

1. Document Concepts

There are 3 main document concepts:

  • Document Types — Type of PDF
  • Periodicity — Every N months it’s required
  • Status — Whether the document is “valid”, “pending”, “missing” or “n/a”

What are Document Types?

Here are some examples:

Document Types Example

The idea is to have default document types, but let admins create types if needed.

The Periodicity determines if the uploaded document needs a period:

  • Once
  • Annually — 1 a year
  • Semiannually — 2 a year
  • Triannually — 3 a year
  • Quarterly — 4 a year
  • Bimonthly — 6 a year
  • Monthly — 12 a year

DTOs

Enums and Types/Interfaces often describe the requirements in a faster way:

For DocumentTypeDto, I have two options:

// Option 1 - Period name
type Periodicity = "once" | "annually" | "semiannually" | "triannually" | "quarterly" | "bimonthly" | "monthly";
interface DocumentTypeDto {
  id?: string;
  name: string;
  description: string;
  periodicity: Periodicity;
}

// Option 2 - Times in year
interface DocumentTypeDto {
  id?: string;
  name: string;
  description: string;
  timesInYear: number | null; // null if "once"
}
Enter fullscreen mode Exit fullscreen mode

Option 1 (Period Name) it’s more readable, while Option 2 (Times in Year) is more functional. I’d rather go with a functional approach.

And DocumentDto will use the DocumentTypeDto interface. Both Dtos need to be on their own file so they can be used throughout the app.

import { MediaDto } from "~/application/dtos/entities/MediaDto";
import { DocumentTypeDto } from "./DocumentTypeDto";

export interface DocumentDto {
  id?: string;
  tenant: { id: string; name: string };
  type: DocumentTypeDto;
  year: number | null;
  period: number | null;
  document: MediaDto;
}
Enter fullscreen mode Exit fullscreen mode

Starting with the End

I like to start with the final design, then walk the features backward:

  1. Documents CRUD — Users can submit documents
  2. Document Types CRUD — Admins can create new types
  3. My Linked Accounts Documents Calendar View — Browse documents from accounts I’ve linked with
  4. Account Documents Calendar View — Users can see all their documents

So I need 2 CRUDs and 2 Calendar Views. The logical way of implementing this is to create the CRUDs and once some data is created, read it with the calendar views. But I like to go straight to the end result with fake data.

2. Custom Account Documents Calendar View

Starting with a “AccountDocuments.tsx” component, I’m going to create a mockup first… no, not with Figma, but Google Sheets 😅:

Account Documents

A few things to notice on the required UI component:

  • Grid — Rows for document types, Columns for months
  • Cell spans — Corresponding number of months a year
  • Check mark — Submitted document
  • X mark — Missing document
  • Clock icon — Missing document, but period has not ended
  • Gray cell — We’ve not reached that period

Periods Column Span

The first thing to notice in this table is the column span of each document type, for example, for “Articles of Incorporation” (once) and “Tax Statement” (annually) the column span is 12, for “Financial Report” (quarterly) is 3, and “Tax Compliance” (monthly) is 1.

Past, Current, Future

How do I know the column span for each document? I need a helper function that returns me an array of:

  • Number — The number 1 for a “monthly” periodicity is “January”, and for a “quarterly” periodicity it’s “Jan-Mar”
  • Name — Friendly identifier
  • From — When the period starts
  • To — When the period ends
function getDocumentTypePeriods(timesInYear: number | null): { from: Date; to: Date; name: string; number: number }[] {
    let periods: { from: Date; to: Date; name: string; number: number }[] = [];
    if (timesInYear === 1 || !timesInYear) {
      periods = [{ number: 1, name: "Jan-Dec", from: new Date(year, 0, 1), to: new Date(year, 11, 31) }];
    } else if (timesInYear === 2) {
      periods = [
        { number: 1, name: "Jan-Jun", from: new Date(year, 0, 1), to: new Date(year, 5, 30) },
        { number: 2, name: "Jul-Dec", from: new Date(year, 6, 1), to: new Date(year, 11, 31) },
      ];
    } else if (timesInYear === 4) {
      // jan-mar, apr-jun, jul-sep, oct-dec
      periods = [
        { number: 1, name: "Jan-Mar", from: new Date(year, 0, 1), to: new Date(year, 2, 31) },
        { number: 2, name: "Apr-Jun", from: new Date(year, 3, 1), to: new Date(year, 5, 30) },
        { number: 3, name: "Jul-Sep", from: new Date(year, 6, 1), to: new Date(year, 8, 30) },
        { number: 4, name: "Oct-Dec", from: new Date(year, 9, 1), to: new Date(year, 11, 31) },
      ];
    } else if (timesInYear === 6) {
      // jan-feb, mar-apr, may-jun, jul-aug, sep-oct, nov-dec
      periods = [
        { number: 1, name: "Jan-Feb", from: new Date(year, 0, 1), to: new Date(year, 1, 28) },
        { number: 2, name: "Mar-Apr", from: new Date(year, 2, 1), to: new Date(year, 3, 30) },
        { number: 3, name: "May-Jun", from: new Date(year, 4, 1), to: new Date(year, 5, 30) },
        { number: 4, name: "Jul-Aug", from: new Date(year, 6, 1), to: new Date(year, 7, 31) },
        { number: 5, name: "Sep-Oct", from: new Date(year, 8, 1), to: new Date(year, 9, 30) },
        { number: 6, name: "Nov-Dec", from: new Date(year, 10, 1), to: new Date(year, 11, 31) },
      ];
    } else if (timesInYear === 12) {
      periods = Array.from(Array(12).keys()).map((idx) => ({
        number: idx + 1,
        name: getMonthName(idx + 1),
        from: new Date(year, idx, 1),
        to: new Date(year, idx + 1, 0),
      }));
    }
    return periods;
  }
Enter fullscreen mode Exit fullscreen mode

Depending on the timesInYear parameter, it would return a different set of periods:

  • getDocumentTypePeriods(1 | null) = [“Jan-Dec”]
  • getDocumentTypePeriods(2) = [“Jan-Jun”, “Jul-Dec”]
  • getDocumentTypePeriods(4) = [“Jan-Mar”, “Apr-Jun”, “Jul-Sep”, “Oct-Dec”]
  • And so on…

Identifying the Timeline

Before displaying any check or X marks, I need to know whether the current cell is on the past, current (present), or future:

function getDocumentTypeTimeline(type: DocumentTypeDto, period: number): "past" | "current" | "future" {
    const today = new Date();
    const periods = getDocumentTypePeriods(type.timesInYear);
    const currentPeriod = periods.find((period) => period.from <= today && period.to >= today);
    if (year < today.getFullYear()) {
      return "past";
    }
    if (year > today.getFullYear()) {
      return "future";
    }
    if (currentPeriod && currentPeriod.number === period) {
      return "current";
    } else if (currentPeriod && currentPeriod.number > period) {
      return "past";
    }
    return "future";
  }
Enter fullscreen mode Exit fullscreen mode

Identifying the Current Document Status

Now that I have each the cell’s column span and current timeline, I need another helper function to check the current document status:

function getDocumentsInPeriod(type: DocumentTypeDto, period: number): DocumentDto[] {
    const yearDocuments = documents.filter((document) => document.year === year || !document.year);
    const typeDocuments = yearDocuments.filter((document) => document.type.name === type.name);
    const periodDocuments = typeDocuments.filter((document) => document.period === period || !document.period);
    return periodDocuments;
  }

function getDocumentInPeriodStatus(type: DocumentTypeDto, period: number): "valid" | "pending" | "missing" | "n/a" {
    const documentsInPeriod = getDocumentsInPeriod(type, period);
    const timeline = getDocumentTypeTimeline(type, period);
    if (documentsInPeriod.length > 0) {
      return "valid";
    }
    if (!type.timesInYear|| timeline === "past") {
      return "missing";
    }
    if (timeline === "future") {
      return "n/a";
    }
    if (timeline === "current") {
      return "pending";
    }
    return "missing";
  }
Enter fullscreen mode Exit fullscreen mode

The full code for this AccountDocuments.tsx component is here in this public gist: gist.github.com/AlexandroMtzG/2dba00f3446f8d89d819aa74fa8d5dca or here’s the design only: play.tailwindcss.com/skAlNv7Q03.

All these helper functions should be in their own “DocumentTypeHelper.ts” file. I’ve placed it at “~/modules/documents/helpers/DocumentTypeHelper.ts”, while the component at “~/modules/documents/components”, and the DTOs at “~/modules/documents/dtos”.

Testing the AccountDocuments Component

I’ve created the following route file at “app/routes/admin/playground/account-documents.tsx”:

Account Documents Testing Route

Notice how I replaced the clock icon with an upload one, that’s because this will help me upload a specific document on a specific period. And since today is February 3rd, 2023 and I did not set a “Tax Compliance” document, it should be on the “missing” status, so a red X icon should be placed.

import { useState } from "react";
import { MediaDto } from "~/application/dtos/entities/MediaDto";
import MyDocuments from "~/modules/documents/components/AccountDocuments";
import { DocumentDto } from "~/modules/documents/dtos/DocumentDto";
import { DocumentTypeDto } from "~/modules/documents/dtos/DocumentTypeDto";
import InputSearch from "~/components/ui/input/InputSearch";
import InputSelect from "~/components/ui/input/InputSelect";
import FakePdfBase64 from "~/components/ui/pdf/FakePdfBase64";

const types: DocumentTypeDto[] = [
  {
    name: "Articles of Incorporation",
    timesInYear: null, // once
    description: "Establishes a corporation as a valid registered business entity",
  },
  {
    name: "Tax Statement",
    timesInYear: 1, // annually
    description: "Calculates the entity's income and the amount of taxes to be paid",
  },
  {
    name: "Financial Report",
    timesInYear: 4, // quarterly
    description: "Income statement, balance sheet, and cash flow statement",
  },
  {
    name: "Tax Compliance",
    timesInYear: 12,
    description: "States whether the taxpayer is complying with its tax obligations",
  },
];

const fakeFile: MediaDto = {
  type: "application/pdf",
  name: "Test.pdf",
  title: "Test",
  document: FakePdfBase64,
};

const fakeTenant = { id: "1", name: "Tenant 1" };
const documents: DocumentDto[] = [
  { type: types[0], tenant: fakeTenant, year: null, period: null, document: fakeFile },
  { type: types[1], tenant: fakeTenant, year: 2023, period: null, document: fakeFile },
  { type: types[2], tenant: fakeTenant, year: 2023, period: 2, document: fakeFile },
  // { type: types[3], tenant: fakeTenant, year: 2023, period: 1, document: fakeFile },
];

export default function () {
  const [year, setYear] = useState(new Date().getFullYear());
  const [searchInput, setSearchInput] = useState<string>("");

  function filteredTypes() {
    return types.filter((t) => t.name.toLowerCase().includes(searchInput.toLowerCase()) || t.description.toLowerCase().includes(searchInput.toLowerCase()));
  }
  return (
    <div className="space-y-2 p-4">
      <div className="space-y-1">
        <h1 className="font-bold text-gray-800">Account Documents View</h1>
      </div>

      <div className="space-y-2">
        <div className="flex space-x-2">
          <div className="w-full">
            <InputSearch value={searchInput} setValue={setSearchInput} />
          </div>
          <div className="w-32">
            <InputSelect
              value={year}
              options={[
                { name: "2023", value: 2023 },
                { name: "2022", value: 2022 },
              ]}
              setValue={(e) => setYear(Number(e))}
            />
          </div>
        </div>
        <MyDocuments year={year} types={filteredTypes()} documents={documents} />
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Up to this point, I have 5 new files, and 1 file edit (admin/playground.tsx):

git changes

Here’s the demo: saasrock-delega-kyfzljxz4-factura.vercel.app/admin/playground/account-documents.

And if you’re a SaasRock Enterprise subscriber, you can get the code up to this point here: github.com/AlexandroMtzG/saasrock-delega/releases/tag/part-4-documents-account-documents-DTOs-component-and-helper.

3. Creating the Document and Document Type Entities

I need a Parent → Children relationship between Document Type (parent) and Document (child), but the Entity Builder does not autogenerate code for relationships, but I’ll implement it sometime this year, I hope.

For these 2 new entities, I’m going to use custom database models (in contrast, the Contracts entity, built in Chapters 2 and 3, is fully dynamic), but I’ll still use the code generator to get a starting point for both entities.

Preparing the Database Schema

First things first. My new prisma schema with the relationships:

model Row {
  id                 String                  @id @default(cuid())
  ...
  signers            Signer[]
+ document           Document?
+ documentType       DocumentType?
}
...
+ model Document {
+  rowId    String       @unique
+  row      Row          @relation(fields: [rowId], references: [id], onDelete: Cascade)
+  typeId   String
+  type     DocumentType @relation(fields: [typeId], references: [rowId])
+  year     Int?
+  period   Int?
+  document String?
+}

+ model DocumentType {
+  rowId       String     @unique
+  row         Row        @relation(fields: [rowId], references: [id], onDelete: Cascade)
+  name        String
+  description String
+  timesInYear Int?
+  documents   Document[]
+}
Enter fullscreen mode Exit fullscreen mode

Notice how the Ids are basically Rows! This means that even though these entities will be custom, they’ll still benefit from being a Row: permissions, tags, and so on.

Modeling the Entities

I need autogenerated code to start customizing, so it makes sense to model the 2 new entities with the corresponding database types.

Document Type properties — name (text), description (text), and timesInYear (optional number). And this entity is of type “Admin”, as it should not appear on the tenant side (at /app/:tenant/document-types):

Document Type Properties

Document properties — typeId (text), year (optional number), period (optional number), and document (media):

Document Properties

As I said, having a “Type (typeId)” property will not automatically generate code for the Document Type → Documents relationship, but at least it will give me something to override. Another thing to note is that “media/MediaDto” properties are a compound of 4 things: title, name, file, type, and file/publicUrl, and on the model, I’m just using a String property, that would require some override as well to create manually the supabase file.

Now I’ll commit the 47 file changes (23 files for each entity and 1 for the schema.prisma file) to start customizing:

git changes

Moving the Routes to their corresponding folder

By default, the code generator places the 6 routes (Index, New, Edit, Tags, Share, and Activity) within the folder “app/routes/admin/entities/code-generator/tests/document-types”.

I’ll cut and paste the “document-types” folder within “app/routes/admin”. But it should be visible for Admins, so I’ll add it to the sidebar using the “AdminSidebar.tsx” file.

...
export const AdminSidebar = (t: TFunction): SideBarItem[] => [
  ...
  {
    title: t("segments.manage"),
    icon: SvgIcon.DASHBOARD,
    path: "",
    items: [
      ...
      {
+       title: "Documents",
+       path: "/admin/documents",
+       icon: <DocumentsBoxIcon className="h-5 w-5 text-white" />,
+       items: [
+        {
+          title: "Document Types",
+          path: "/admin/document-types",
+        },
+       ],
      },
      ...
Enter fullscreen mode Exit fullscreen mode

Now my autogenerated routes should be visible at /admin/document-types:

Document Types Index Route

… and the “documents” folder, should be placed in “app/routes/app.$tenant”:

Documents Index Route

Now it’s visible at “/app/:tenant/documents”.

This should give me about 12 file index renames (6 files for each entity) and 1 AdminSidebar file change:

git changes

Overriding the “Document Types” Autogenerated Code

If I create a Document Type row, it will save it, but not as I want it to. I want it to create a DocumentType record, and right now it’s using dynamic values. I can verify this by querying the model in my database (I’m using DBeaver with a local postgres database in Postgres.app):

SELECT * FROM “DocumentType”;

The first thing I need to do, is to override the “DocumentTypesService.create()” method to manually set the values:

...
+ import { Prisma } from "@prisma/client";
export namespace DocumentTypeService {
  ...
  export async function create(data: DocumentTypeCreateDto, session: { tenantId: string | null; userId?: string }): Promise<DocumentTypeDto> {
    const entity = await getEntity();
-   const rowValues = RowHelper.getRowPropertiesFromForm({
-     entity,
-      values: [
-        { name: "name", value: data.name },
-        { name: "description", value: data.description },
-        { name: "timesInYear", value: data.timesInYear?.toString() },
-      ],
-   });
    const item = await RowsApi.create({
      tenantId: session.tenantId,
      userId: session.userId,
      entity,
-     rowValues,
+     rowCreateInput: {
+      documentType: {
+        create: {
+          name: data.name,
+          description: data.description,
+          timesInYear: data.timesInYear,
+          },
+        },
+      },
    });
    return DocumentTypeHelpers.rowToDto({ entity, row: item });
  }
  ...
}
Enter fullscreen mode Exit fullscreen mode

After this change, when I create a new “DocumentType”, the database model will be registered correctly, without losing its relationship as a “Row”:

SELECT * FROM “DocumentType”;

The same type of override for updating:

export namespace DocumentTypeService {
  ...
  export async function update(id: string, data: Partial<DocumentTypeDto>, session: { tenantId: string | null; userId?: string }): Promise<DocumentTypeDto> {
    const entity = await getEntity();
    const row = await getRowById(id);
    if (!row) {
      throw Error("Not found");
    }
-    const values: RowValueUpdateDto[] = [];
-    if (data.name !== undefined) {
-      values.push({ name: "name", textValue: data.name });
-    }
-    if (data.description !== undefined) {
-      values.push({ name: "description", textValue: data.description });
-    }
-    if (data.timesInYear !== undefined) {
-      values.push({ name: "timesInYear", numberValue: data.timesInYear });
-    }
+    const rowUpdateInput: Partial<Prisma.RowUpdateInput> = {
+     documentType: {
+       update: {
+         name: data.name,
+         description: data.description,
+         timesInYear: data.timesInYear,
+       },
+     },
+   };
    const item = await RowValueHelper.update({
      entity,
      row,
-     values,
+     rowUpdateInput,
      session,
    });
    return DocumentTypeHelpers.rowToDto({ entity, row: item });
  }
  ...
}
Enter fullscreen mode Exit fullscreen mode

But I’m not using dynamic properties anymore, but hardcoded models (DocumentType, and Document). So I need to tell the database to include those tables at “app/utils/db/entities/rows.db.server.ts”:

// rows.db.server.ts
import {
  ...
+ DocumentType,
+ Document,
} from "@prisma/client";
...
export type RowWithDetails = Row & {
  createdByUser: UserSimple | null;
  ...
  signers: (Signer & { tenant: Tenant; user: UserSimple })[];
+ documentType: DocumentType | null;
+ document: (Document & { type: DocumentType }) | null;
};

export const includeRowDetails = {
  ...includeSimpleCreatedByUser,
  createdByApiKey: true,
  ...
  signers: { include: { tenant: true, user: { select: UserUtils.selectSimpleUserProperties } } },
+ documentType: true,
+ document: { include: { type: true } },
};
Enter fullscreen mode Exit fullscreen mode

And map these values in the “DocumentTypeHelpers.rowToDto()” function:

// DocumentTypeHelpers.ts
...
+ import RowValueHelper from "~/utils/helpers/RowValueHelper";

function rowToDto({ entity, row }: { entity: EntityWithDetails; row: RowWithDetails }): DocumentTypeDto {
  return {
    row,
    prefix: entity.prefix,
-   name: RowValueHelper.getText({ entity, row, name: "name" }) ?? "", // required
-   description: RowValueHelper.getText({ entity, row, name: "description" }) ?? "", // required
-   timesInYear: RowValueHelper.getNumber({ entity, row, name: "timesInYear" }), // optional
+   name: row.documentType!.name,
+   description: row.documentType!.description,
+   timesInYear: row.documentType!.timesInYear ?? undefined,
  };
}
Enter fullscreen mode Exit fullscreen mode

And that’s all! But I’m going to improve it a little bit more. Filters are not working anymore, since I’m using custom database properties, I need to manually filter:

...
+ import RowFiltersHelper from "~/utils/helpers/RowFiltersHelper";

export namespace DocumentTypeService {
  ...
  export async function getAll({ tenantId, userId, urlSearchParams }: {
    tenantId: string | null;
    userId?: string;
-     urlSearchParams: URLSearchParams;
+     urlSearchParams?: URLSearchParams;
  }): Promise<{ items: DocumentTypeDto[]; pagination: PaginationDto }> {
    const entity = await getEntity();
+     let rowWhere: Prisma.RowWhereInput = {};
+    const propertiesToFilter = ["q", "id", "folio", "name", "description", "timesInYear"];
+    if (propertiesToFilter.some((p) => urlSearchParams?.has(p))) {
+      rowWhere = {
+        documentType: {
+          OR: [
+            { name: { contains: RowFiltersHelper.getParam_String(urlSearchParams, ["q", "name"]) } },
+            { description: { contains: RowFiltersHelper.getParam_String(urlSearchParams, ["q", "description"]) } },
+            { timesInYear: { equals: RowFiltersHelper.getParam_Number(urlSearchParams, ["q", "timesInYear"]) } },
+          ],
+        },
+      };
+    }
    const data = await RowsApi.getAll({
      entity,
      tenantId,
      userId,
-     urlSearchParams,
+     rowWhere,
    });
    return {
      items: data.items.map((row) => DocumentTypeHelpers.rowToDto({ entity, row })),
      pagination: data.pagination,
    };
  }
  ...
}
Enter fullscreen mode Exit fullscreen mode

The object rowWhere is basically exposing the Prisma types for filtering. You can inspect the “RowFiltersHelper.getParam()”_ … helper functions, but they basically get the filters from the URL, both from the global search parameter called “q”, and the parameter name itself (like “name”, or “description”).

Finally, I can customize which filters I want to be shown at the route API:

...
export namespace DocumentTypeRoutesIndexApi {
  ...
  export let loader: LoaderFunction = async ({ request, params }) => {
    const data: LoaderData = {
      metadata: { title: "Document Type | " + process.env.APP_NAME },
      items,
      pagination,
-       filterableProperties: EntityHelper.getFilters({ t, entity: await getEntityByName("documentType") }),
+       filterableProperties: [
+        { name: "name", title: "Name" },
+        { name: "description", title: "Description" },
+        { name: "timesInYear", title: "Times in Year" },
+      ],
    };
    ...
Enter fullscreen mode Exit fullscreen mode

End result:

Document Types End Result

I’ll commit the override of the DocumentTypes CRUD (5 files):

git changes

Overriding the “Document” Autogenerated Code

I’ll do the same steps I did for the “Document Types” override, so I’ll skip the “DocumentService.ts” file modifications (create, update, and getAll functions).

Whenever there are files involved, there needs to be some custom mapping to use the MediaDto interface, remember I mentioned that before (title, name, file, type, and file/publicUrl)?

...
+ import RowValueHelper from "~/utils/helpers/RowValueHelper";
+ import { MediaDto } from "~/application/dtos/entities/MediaDto";

function rowToDto({ entity, row }: { entity: EntityWithDetails; row: RowWithDetails }): DocumentDto {
  return {
    row,
    prefix: entity.prefix,
-   typeId: RowValueHelper.getText({ entity, row, name: "typeId" }) ?? "", // required
-   year: RowValueHelper.getNumber({ entity, row, name: "year" }), // optional
-   period: RowValueHelper.getNumber({ entity, row, name: "period" }), // optional
-   document: RowValueHelper.getFirstMedia({ entity, row, name: "document" }) as MediaDto, // required
+   typeId: row.document?.typeId ?? "",
+   year: row.document?.year ?? undefined,
+   period: row.document?.period ?? undefined,
+   document: {
+     type: "application/pdf",
+     file: row.document?.document ?? "",
+     name: (row.documentType?.name ?? row.id) + ".pdf",
+     title: row.documentType?.name ?? row.id,
+   },
  };
}
...
Enter fullscreen mode Exit fullscreen mode

And one important thing to note here is that I don’t even need to map the “type” object because it’s in the row itself. See how I’m using it to render it on the index route view:

...
export default function DocumentRoutesIndexView() {
  ...
  return (
    <IndexPageLayout
      title={t("Documents")}
      ...
    >
      <TableSimple
        items={data.items}
        ...
        headers={[
          ...
          {
            name: "typeId",
            title: t("Type"),
-           value: (item) => <div className="max-w-sm truncate">{item.typeId}</div>,
+           value: (item) => <div className="max-w-sm truncate">{item.row.document?.type?.name}</div>,
          },
          ...

Enter fullscreen mode Exit fullscreen mode

Right now, the Document Form renders the Type (typeId) property as text.

New Document Form

But this should be a selector showing all the Document Types registered. And I should get ALL document types, bypassing row permissions.

I could do this using the “DocumentRoutes.New.Api.ts” LoaderData, but Document Types are going to be used throughout the application (for example onboarding or something else), so it would make sense to always have them on the /app data.

// app/utils/data/useAppData.ts
...
+ import { DocumentTypeService } from "~/modules/codeGeneratorTests/document-types/services/DocumentTypeService";
+ import { DocumentTypeDto } from "~/modules/codeGeneratorTests/document-types/dtos/DocumentTypeDto";

export type AppLoaderData = AppOrAdminData & {
  currentTenant: TenantWithDetails;
  ...
+ documentTypes: DocumentTypeDto[];
};
...

export async function loadAppData(request: Request, params: Params) {
  ...
  const data: AppLoaderData = {
    ...
+   documentTypes: (await DocumentTypeService.getAll({ tenantId: null, userId: undefined })).items,
  };
  return data;
}
Enter fullscreen mode Exit fullscreen mode

And use this using the helper function “useAppData()” in the “DocumentForm.tsx”:

...
export default function DocumentForm({ ... }) {
+   const appData = useAppData();
  const transition = useTransition();
  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 />}

      <InputGroup title={t("shared.details")}>
        <div className="space-y-2">
-         <InputText name="typeId" title={t("Type")} required autoFocus disabled={isDisabled()} value={item?.typeId} />
+         <InputSelector
+          name="typeId"
+          title={t("Type")}
+          required
+          autoFocus
+          disabled={isDisabled()}
+          value={item?.typeId ?? (isCreating && appData.documentTypes.length > 0) ? appData.documentTypes[0].row.id : undefined}
+          options={appData.documentTypes.map((x) => ({ value: x.row.id, name: x.name }))}
+          withSearch={false}
          />
          ...
Enter fullscreen mode Exit fullscreen mode

This would now render the property control correctly, listing all the document types available:

New Document Form

And finally, I want the filters to be “year” and “typeId” only. And type should be a list of the document types:

...
export namespace DocumentRoutesIndexApi {
  ...
  export let loader: LoaderFunction = async ({ request, params }) => {
+   const allDocumentTypes = (await DocumentTypeService.getAll({ tenantId: null, userId: undefined })).items;
    const data: LoaderData = {
      metadata: { title: "Document Type | " + process.env.APP_NAME },
      items,
      pagination,
-     filterableProperties: EntityHelper.getFilters({ t, entity: await getEntityByName("documentType") }),
+     filterableProperties: [
+      { name: "year", title: "Year" },
+      {
+        name: "typeId",
+        title: "Type",
+        options: allDocumentTypes.map((f) => {
+          return { value: f.row.id, name: f.name };
+          }),
+        },
+      ],
    };
    ...
Enter fullscreen mode Exit fullscreen mode

End result:

Documents End Result

Storing the Supabase File

Since I went out of the dynamic properties, now it doesn’t automatically store my file in a cloud storage provider (Supabase) anymore. So I need to do it manually:

...
export namespace DocumentService {
  ...
  export async function create(data: DocumentCreateDto, session: { tenantId: string | null; userId?: string }): Promise<DocumentDto> {
    const entity = await getEntity();
+     const randomId = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
+     const documentPublicUrl = await storeSupabaseFile({
+      bucket: "documents",
+      content: data.document.file,
+      id: randomId + ".pdf",
+    });
    const item = await RowsApi.create({
      ...
      rowCreateInput: {
        document: {
          create: {
              ...
-             document: data.document.file,
+             document: documentPublicUrl,
          },
        },
      },
    });
    ...
Enter fullscreen mode Exit fullscreen mode

See how I’m storing it with a randomId in a bucket called “documents”. After creating another document, if I go to my supabase dashboard, I will have the bucket with my file:

Supabase Buckets Dashboard

But I need to handle file updates, and deletions as well:

...
export namespace DocumentService {
  ...
  export async function update(id: string, data: Partial<DocumentDto>, session: { tenantId: string | null; userId?: string }): Promise<DocumentDto> {
    const entity = await getEntity();
    const row = await getRowById(id);
    if (!row) {
      throw Error("Not found");
    }
+   let newDocumentPublicUrl: string | undefined;
+   if (data.document?.file) {
+     const fileName = row?.document?.document?.split("/").pop();
+     if (fileName) {
+        await deleteSupabaseFile("documents", fileName);
+        const randomId = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
+        newDocumentPublicUrl = await storeSupabaseFile({
+          bucket: "documents",
+          content: data.document.file,
+          id: randomId + ".pdf",
+        });
+      }
+    }
    const rowUpdateInput: Partial<Prisma.RowUpdateInput> = {
      document: {
        update: {
          ...
-         document: data.document?.file,
+         document: newDocumentPublicUrl,
        },
      },
    };
    ...
  }
  export async function del(id: string, session: { tenantId: string | null; userId?: string }): Promise<void> {
    const entity = await getEntity();
    const item = await get(id, session);
+     const fileName = item?.document.file.split("/").pop();
+    if (fileName) {
+      await deleteSupabaseFile("documents", fileName);
+    }
    await RowsApi.del(id, {
      entity,
      tenantId: session.tenantId,
      userId: session.userId,
    });
  }
Enter fullscreen mode Exit fullscreen mode

So it’s a little manual work but at the same time full control over my buckets and files. I could improve file naming, or support other extensions, not only .pdf, by adding a name and type properties to the Document model, but this is just MVP. Here’s the deployed demo.

Deployed Demo

For SaasRock Enterprise subscribers, the release up to this point is here: github.com/AlexandroMtzG/saasrock-delega/releases/tag/part-4-documents-customized-CRUDs, and here’s the entity configuration I’m using so far.

Or just watch the video demo of the progress so far: https://www.loom.com/share/590c83521b6642269587575b6d9b2cc8?t=20

4. Scanning a Document with Tesseract.js

First, I’m going to install tesseract.js:

npm i tesseract.js@4.0.2
Enter fullscreen mode Exit fullscreen mode

Tesseract can’t actually scan PDFs, so first I need to convert my images.

I built a tool a simple API to convert PDF to Images at https://tools.saasrock.com/api/pdf-to-image?file=XXX but you can use any method you want:

/api/pdf-to-image

I’m going to wrap this API call into a “PdfService”:

// PdfService.ts

async function convertToImages({ file }: { file: string }): Promise<{ name: string; base64: string; path: string }[]> {
  return new Promise(async (resolve, reject) => {
    await fetch("https://tools.saasrock.com/api/pdf-to-image?file=" + file)
      .then(async (response) => {
        const jsonBody = await response.json();
        const images = jsonBody.images as { name: string; base64: string; path: string }[];
        resolve(images);
      })
      .catch((e) => {
        reject(e);
      });
  });
}
export default {
  convertToImages,
};
Enter fullscreen mode Exit fullscreen mode

If you’re curious about the code, here’s a public gist (ignore the commented code, I tried too many ways and this was the only way it worked in vercel), I used this repo for reference.

And build a simple “OcrTesseractService” to consume it. Here’s the gist.

// OcrTesseractService.ts

import Tesseract from "tesseract.js";
import PdfService from "./PdfService";

export const OcrTesseractLanguages = [
  { name: "English", value: "eng" },
  { name: "Spanish", value: "spa" },
];

async function scan(file: string, lang: string): Promise<string> {
  return await new Promise(async (resolve, reject) => {
    try {
      if (file.endsWith(".pdf") || file.startsWith("data:application/pdf")) {
        const images = await PdfService.convertToImages({ file });
        console.log({ images });
        let text = "";
        for (const image of images) {
          text += await scanImage(image.base64, lang);
        }

        // const text = await PdfService.convertToText(file);
        resolve(text);
      } else {
        // OCR
        const text = await scanImage(file, lang);
        resolve(text);
      }
    } catch (e) {
      // eslint-disable-next-line no-console
      console.log(e);
      reject(e);
    }
  });
}

async function scanImage(file: string, lang: string): Promise<string> {
  // eslint-disable-next-line no-console
  console.log("[OCR] Scanning image: ", file, lang);
  return await new Promise(async (resolve, reject) => {
    let image = file;
    await Tesseract.recognize(image, lang, {
      logger: (m) => {
        // eslint-disable-next-line no-console
        console.log("[OCR] Logger: ", JSON.stringify(m));
      },
    })
      .then(({ data: { text } }) => {
        // eslint-disable-next-line no-console
        console.log("[OCR] Result: ", text);
        resolve(text);
      })
      .catch((e) => {
        // eslint-disable-next-line no-console
        console.log("[OCR] Error: ", e);
        reject(e);
      });
  });
}

export default {
  scan,
};
Enter fullscreen mode Exit fullscreen mode

And call this function inside the “DocumentRoutes.Edit.View” component:

+ import { useRef, useState } from "react";
+ import OcrTesseractService from "~/modules/ocr/OcrTesseractService";
+ import ActionResultModal from "~/components/ui/modals/ActionResultModal";

export default function DocumentRoutesEditView() {
  ...
+  const [scanningState, setScanningState] = useState<{ status: "idle" | "loading" | "error" | "success"; result?: string; error?: string }>();
+    async function onOcr() {
+      setScanningState({ status: "loading", result: undefined, error: undefined });
+      try {
+        const text = await OcrTesseractService.scan(data.item.document.file, "spa");
+        if (!text) {
+          setScanningState({ status: "error", error: "Unknown error" });
+        }
+        setScanningState({ status: "success", result: text });
+      } catch (e: any) {
+        setScanningState({ status: "error", error: e.message });
+      }
+    }
  return (
     ...
+         {appOrAdminData.isSuperAdmin && (
+           <ButtonSecondary onClick={onOcr} disabled={scanningState?.status === "loading"}>
+             <div className="text-xs">{scanningState?.status === "loading" ? "Scanning..." : "Scan"}</div>
+           </ButtonSecondary>
+         )}
          <ButtonSecondary to="activity">
            <ClockIcon className="h-4 w-4 text-gray-500" />
          </ButtonSecondary>
          ...
+         <ActionResultModal
+            actionResult={{
+              error: scanningState?.status === "error" ? { title: "Error", description: scanningState?.error ?? "" } : undefined,
+              success: scanningState?.status === "success" ? { title: "Success", description: scanningState?.result ?? "" } : undefined,
+            }}
+        />
Enter fullscreen mode Exit fullscreen mode

And with those 3 file modifications (plus the tesseract.js installation), I have a working scanning feature:

git changes

PDF Scanned Result

If you’re a SaasRock Enterprise subscriber, here’s the release for the current progress: github.com/AlexandroMtzG/saasrock-delega/releases/tag/part-4-documents-ocr-with-tesseract.js.

5. Using the Custom Calendar View

Right now, the default Documents Index View is a table, but I want to use the “Calendar View” I designed called “AccountDocuments.tsx”. I need to use the new DTOs (DocumentDto and DocumentTypeDto) instead of the fake ones (and delete them), so I can replace the table with the component:

...
import AccountDocuments from "~/modules/documents/components/AccountDocuments";
import { useAppData } from "~/utils/data/useAppData";
import InputSearch from "~/components/ui/input/InputSearch";
import InputSelect from "~/components/ui/input/InputSelect";

export default function DocumentRoutesIndexView() {
  const { t } = useTranslation();
+ const appData = useAppData();
  ...
+ const [year, setYear] = useState(new Date().getFullYear());
+ const [searchInput, setSearchInput] = useState<string>("");
  ...
+ function filteredTypes() {
+  return appData.documentTypes.filter(
+    (t) => t.name.toLowerCase().includes(searchInput.toLowerCase()) || t.description.toLowerCase().includes(searchInput.toLowerCase())
+  );
+ }
  return (
    ...
-   <TableSimple ... />
+   <div className="flex space-x-2">
+      <div className="w-full">
+        <InputSearch value={searchInput} setValue={setSearchInput} />
+      </div>
+      <div className="w-32">
+        <InputSelect
+          value={year}
+          options={[
+            { name: "2023", value: 2023 },
+            { name: "2022", value: 2022 },
+          ]}
+          setValue={(e) => setYear(Number(e))}
+        />
+      </div>
+    </div>

+   <AccountDocuments year={year} types={filteredTypes()} documents={data.items} />
      ...
Enter fullscreen mode Exit fullscreen mode

And this will actually be functional:

Documents Index View

But I want some important improvements for great UX:

  • Clicking on a “missing document” cell should redirect to the /new route with typeId, year, and period on the query params. And if the “Document.New.Api” should redirect to the calendar instead of redirecting to “Document.Edit.View”.
  • Clicking on a “valid document” cell should open the side modal overviewing the document.
  • “DocumentForm” should be interactive: if the selected -DocumentType is of type “timesInYear = 12” the period selector should display all the months, but if “timesInYear = 4” it should display “Jan-Mar”, “Apr-Jun”...
  • The “Document.New.Api” should throw an error if the uploaded document has already been uploaded.

I won’t write the code diff for these improvements, but here are some images so you get the idea and the corresponding gist code:

AccountDocuments.tsx:

Each cell now has an onClick handler, and based on the document status it will render the correct color when hovering. Gist here.

AccountDocuments.tsx

DocumentRoutes.Index.View.tsx:

Remove the default table and replace it with the “AccountDocuments.tsx” component. Also, I’m listing the possible years, instead of having to filter by typing the year. Gist here.

DocumentRoutes.Index.View.tsx

DocumentForm.tsx:

Now the form needs to be reactive. Whenever the document type is set, the Period selector should display the valid options. Also, if it’s creating a new Document, it will see if the query parameters are set, or fall back to default values. Gist here.

DocumentForm.tsx

DocumentRoutes.New.Api.tsx:

I should verify if the document has been created before, so I don’t have more than 1 document for the same tenantId, typeId, year, and period.

DocumentRoutes.New.Api.tsx

6. Viewing my Linked Accounts Documents

There are still 2 major problems now:

  • The current Documents Index Route lists all the current tenant’s documents as well as the linked account’s documents. That’s because I set the Documents entity’s default visibility to “Linked Accounts”, this automatically shares with every account linked. And the default “RowsApi.getAll()” function gets every row you have access to. I need to tell it to retrieve only a specific tenant’s documents.
  • I need to identify which tenant/account documents are we looking at. And if it’s not the current tenant’s documents, he could not upload (hover to add -> disabled), only view.

For this MVP, I can kill two birds with one stone by having a Tenant/Account selector and showing the “AccountDocuments.tsx” calendar view with the selected Tenant (if not set, display the current one).

Final Documents View

😮‍💨… the longest article so far!


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-4-documents-linked-account-selector-to-view-documents.


What’s next?

In chapter 5, I’ll start working on the Pricing model:

  • Plans and Prices
  • Creating the Plans in Stripe
  • Plan Limits

...and more subscription-related stuff.

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

Top comments (0)