feat: add basic document preview and update document schema (#120)

* feat: add react pdf

* fix: upload provider enum

* feat: add pdf viewer component

* feat: add company url

* feat: add get document procedure

* feat: add get all documents procedure

* feat: add create document procedure

* fix: schema

* feat; add all procedures

* fix: remove explicit type casting

* feat: update document schema

* feat: make uploader component more generic

* feat: use trpc and trpc types

* feat: use company id

* feat: add page layout component

* feat: add equity document upload page

* feat: add document detail page

* featL add pdf worker

* feat: rename props

* feat: add templates page

* feat : add bucket model

* feat: add relation for bucket

* feat: refactor document fields

* feat: update schema

* feat: add status for template

* feat: add bucket router

* feat: add create template procedure

* fix: relation

* feat: migration

* feat: add get template procedure

* feat: update document schema

* feat: use bucket procedure for uploader

* feat: use relavant procedures

* feat: use template for query

* feat: use data

* fix: provider enum

* feat: add order by

* feat: add new fields

* fix: eslint and build error and some minor changes

---------

Co-authored-by: Puru D <[email protected]>
This commit is contained in:
Nafees Nazik
2024-02-15 20:39:39 -06:00
committed by GitHub
co-authored by Puru D
parent 3e63fb1a97
commit 36b11e3606
34 changed files with 894 additions and 114 deletions
+1
View File
@@ -0,0 +1 @@
public/pdf.worker.min.js
+11
View File
@@ -1,3 +1,5 @@
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful
* for Docker builds.
@@ -9,6 +11,15 @@ const config = {
images: {
domains: ["randomuser.me"],
},
webpack: (config) => {
/**
* Critical: prevents " ⨯ ./node_modules/canvas/build/Release/canvas.node
* Module parse failed: Unexpected character '�' (1:0)" error
*/
config.resolve.alias.canvas = false;
return config;
},
async redirects() {
return [
+4 -1
View File
@@ -17,7 +17,8 @@
"start": "next start",
"format": "prettier --write \"**/*.{css,js,json,jsx,ts,tsx}\" && npx prisma format",
"email:preview": "email preview ./src/emails",
"smpt:dev": "mailpit"
"smpt:dev": "mailpit",
"copy:pdfjs": "node scripts/copy-pdfjs-worker.cjs"
},
"dependencies": {
"@ant-design/plots": "^2.1.12",
@@ -50,6 +51,7 @@
"@trpc/next": "^10.43.6",
"@trpc/react-query": "^10.43.6",
"@trpc/server": "^10.43.6",
"@wojtekmaj/react-hooks": "^1.18.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"cmdk": "^0.2.0",
@@ -67,6 +69,7 @@
"react-dom": "18.2.0",
"react-dropzone": "^14.2.3",
"react-hook-form": "^7.49.3",
"react-pdf": "^7.7.0",
"server-only": "^0.0.1",
"sharp": "^0.33.2",
"superjson": "^2.2.1",
@@ -0,0 +1,76 @@
/*
Warnings:
- You are about to drop the column `uploadedById` on the `Document` table. All the data in the column will be lost.
- A unique constraint covering the columns `[publicId]` on the table `Document` will be added. If there are existing duplicate values, this will fail.
- Added the required column `mimeType` to the `Document` table without a default value. This is not possible if the table is not empty.
- Added the required column `publicId` to the `Document` table without a default value. This is not possible if the table is not empty.
- Added the required column `uploadProvider` to the `Document` table without a default value. This is not possible if the table is not empty.
- Added the required column `uploaderId` to the `Document` table without a default value. This is not possible if the table is not empty.
- Changed the type of `type` on the `Document` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required.
*/
-- CreateEnum
CREATE TYPE "UploadProviders" AS ENUM ('S3', 'R2');
-- CreateEnum
CREATE TYPE "DocumentType" AS ENUM ('SAFE', 'EQUITY', 'GENERIC');
-- CreateEnum
CREATE TYPE "DocumentStatus" AS ENUM ('DRAFT', 'COMPLETED');
-- CreateEnum
CREATE TYPE "DocumentFieldTypes" AS ENUM ('TEXT', 'TEXT_AREA', 'RADIO', 'CHECK_BOX', 'SIGNATURE', 'DATE', 'DATE_TIME', 'EMAIL');
-- CreateEnum
CREATE TYPE "DocumentSigningStatus" AS ENUM ('NOT_SIGNED', 'SIGNED');
-- DropIndex
DROP INDEX "Document_uploadedById_idx";
-- AlterTable
ALTER TABLE "Document" DROP COLUMN "uploadedById",
ADD COLUMN "mimeType" TEXT NOT NULL,
ADD COLUMN "publicId" TEXT NOT NULL,
ADD COLUMN "status" "DocumentStatus" NOT NULL DEFAULT 'DRAFT',
ADD COLUMN "uploadProvider" "UploadProviders" NOT NULL,
ADD COLUMN "uploaderId" TEXT NOT NULL,
DROP COLUMN "type",
ADD COLUMN "type" "DocumentType" NOT NULL;
-- CreateTable
CREATE TABLE "DocumentFields" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"type" "DocumentFieldTypes" NOT NULL DEFAULT 'TEXT',
"placeholder" TEXT NOT NULL DEFAULT '',
"required" BOOLEAN NOT NULL DEFAULT false,
"documentId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DocumentFields_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DocumentRecepients" (
"id" TEXT NOT NULL,
"signedStatus" "DocumentSigningStatus" NOT NULL,
"membershipId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DocumentRecepients_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "DocumentFields_documentId_idx" ON "DocumentFields"("documentId");
-- CreateIndex
CREATE INDEX "DocumentRecepients_membershipId_idx" ON "DocumentRecepients"("membershipId");
-- CreateIndex
CREATE INDEX "Document_uploaderId_idx" ON "Document"("uploaderId");
-- CreateIndex
CREATE UNIQUE INDEX "Document_publicId_key" ON "Document"("publicId");
@@ -0,0 +1,128 @@
/*
Warnings:
- You are about to drop the column `key` on the `Document` table. All the data in the column will be lost.
- You are about to drop the column `mimeType` on the `Document` table. All the data in the column will be lost.
- You are about to drop the column `size` on the `Document` table. All the data in the column will be lost.
- You are about to drop the column `status` on the `Document` table. All the data in the column will be lost.
- You are about to drop the column `type` on the `Document` table. All the data in the column will be lost.
- You are about to drop the column `uploadProvider` on the `Document` table. All the data in the column will be lost.
- You are about to drop the `DocumentFields` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `DocumentRecepients` table. If the table is not empty, all the data it contains will be lost.
- Added the required column `bucketId` to the `Document` table without a default value. This is not possible if the table is not empty.
*/
-- CreateEnum
CREATE TYPE "FieldTypes" AS ENUM ('text', 'textArea', 'radio', 'checkBox', 'signature', 'date', 'dateTime', 'email');
-- CreateEnum
CREATE TYPE "TemplateStatus" AS ENUM ('draft', 'completed');
-- CreateEnum
CREATE TYPE "EsignRecipientStatus" AS ENUM ('notSigned', 'signed');
-- AlterTable
ALTER TABLE "Document" DROP COLUMN "key",
DROP COLUMN "mimeType",
DROP COLUMN "size",
DROP COLUMN "status",
DROP COLUMN "type",
DROP COLUMN "uploadProvider",
ADD COLUMN "bucketId" TEXT NOT NULL;
-- DropTable
DROP TABLE "DocumentFields";
-- DropTable
DROP TABLE "DocumentRecepients";
-- DropEnum
DROP TYPE "DocumentFieldTypes";
-- DropEnum
DROP TYPE "DocumentSigningStatus";
-- DropEnum
DROP TYPE "DocumentStatus";
-- DropEnum
DROP TYPE "DocumentType";
-- DropEnum
DROP TYPE "UploadProviders";
-- CreateTable
CREATE TABLE "Bucket" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"key" TEXT NOT NULL,
"mimeType" TEXT NOT NULL,
"size" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Bucket_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TemplateField" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"type" "FieldTypes" NOT NULL DEFAULT 'text',
"placeholder" TEXT NOT NULL DEFAULT '',
"required" BOOLEAN NOT NULL DEFAULT false,
"positionX" DECIMAL(65,30) NOT NULL,
"positionY" DECIMAL(65,30) NOT NULL,
"width" DECIMAL(65,30) NOT NULL,
"height" DECIMAL(65,30) NOT NULL,
"templateId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TemplateField_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Template" (
"id" TEXT NOT NULL,
"publicId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"status" "TemplateStatus" NOT NULL DEFAULT 'draft',
"bucketId" TEXT NOT NULL,
"uploaderId" TEXT NOT NULL,
"companyId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Template_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "EsignRecipient" (
"id" TEXT NOT NULL,
"email" TEXT,
"status" "EsignRecipientStatus" NOT NULL,
"membershipId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "EsignRecipient_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "TemplateField_templateId_idx" ON "TemplateField"("templateId");
-- CreateIndex
CREATE INDEX "Template_bucketId_idx" ON "Template"("bucketId");
-- CreateIndex
CREATE INDEX "Template_uploaderId_idx" ON "Template"("uploaderId");
-- CreateIndex
CREATE INDEX "Template_companyId_idx" ON "Template"("companyId");
-- CreateIndex
CREATE INDEX "EsignRecipient_membershipId_idx" ON "EsignRecipient"("membershipId");
-- CreateIndex
CREATE INDEX "Document_bucketId_idx" ON "Document"("bucketId");
@@ -0,0 +1,15 @@
/*
Warnings:
- Added the required column `verificationTokenToken` to the `EsignRecipient` table without a default value. This is not possible if the table is not empty.
- Added the required column `order` to the `TemplateField` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "EsignRecipient" ADD COLUMN "verificationTokenToken" TEXT NOT NULL;
-- AlterTable
ALTER TABLE "TemplateField" ADD COLUMN "order" INTEGER NOT NULL;
-- CreateIndex
CREATE INDEX "EsignRecipient_verificationTokenToken_idx" ON "EsignRecipient"("verificationTokenToken");
+124 -15
View File
@@ -51,7 +51,6 @@ model User {
accounts Account[]
sessions Session[]
companies Membership[]
documents Document[]
}
model WaitlistUser {
@@ -61,9 +60,10 @@ model WaitlistUser {
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
identifier String
token String @unique
expires DateTime
eSignRecipients EsignRecipient[]
@@unique([identifier, token])
}
@@ -89,6 +89,7 @@ model Company {
shareClasses ShareClass[]
equityPlans EquityPlan[]
documents Document[]
templates Template[]
@@unique([publicId])
}
@@ -123,6 +124,10 @@ model Membership {
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
documentReceived EsignRecipient[]
documents Document[]
templates Template[]
@@unique([companyId, userId])
@@index([companyId])
@@index([status])
@@ -230,21 +235,125 @@ model EquityPlan {
@@index([companyId])
}
model Document {
id String @id @default(cuid())
name String
type String
size Int
key String
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
model Bucket {
id String @id @default(cuid())
name String
key String
mimeType String
size Int
uploadedById String?
uploadedBy User? @relation(fields: [uploadedById], references: [id], onDelete: Cascade)
documents Document[]
templates Template[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Document {
id String @id @default(cuid())
publicId String
name String
bucketId String
bucket Bucket @relation(fields: [bucketId], references: [id])
uploaderId String
uploader Membership @relation(fields: [uploaderId], references: [id])
companyId String
company Company @relation(fields: [companyId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([publicId])
@@index([bucketId])
@@index([uploaderId])
@@index([companyId])
@@index([uploadedById])
}
enum FieldTypes {
text
textArea
radio
checkBox
signature
date
dateTime
email
}
model TemplateField {
id String @id @default(cuid())
name String
type FieldTypes @default(text)
placeholder String @default("")
required Boolean @default(false)
positionX Decimal
positionY Decimal
width Decimal
height Decimal
order Int
Template Template @relation(fields: [templateId], references: [id])
templateId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([templateId])
}
enum TemplateStatus {
draft
completed
}
model Template {
id String @id @default(cuid())
publicId String
name String
status TemplateStatus @default(draft)
bucketId String
bucket Bucket @relation(fields: [bucketId], references: [id])
uploaderId String
uploader Membership @relation(fields: [uploaderId], references: [id])
companyId String
company Company @relation(fields: [companyId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
fields TemplateField[]
@@index([bucketId])
@@index([uploaderId])
@@index([companyId])
}
enum EsignRecipientStatus {
notSigned
signed
}
model EsignRecipient {
id String @id @default(cuid())
email String?
status EsignRecipientStatus
membershipId String?
membership Membership? @relation(fields: [membershipId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
token VerificationToken @relation(fields: [verificationTokenToken], references: [token])
verificationTokenToken String
@@index([membershipId])
@@index([verificationTokenToken])
}
+22
View File
File diff suppressed because one or more lines are too long
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env node
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-var-requires */
const path = require("path");
const fs = require("fs");
const pdfjsDistPath = path.dirname(require.resolve("pdfjs-dist/package.json"));
const pdfWorkerPath = path.join(pdfjsDistPath, "build", "pdf.worker.min.js");
fs.copyFileSync(pdfWorkerPath, "./public/pdf.worker.min.js");
@@ -0,0 +1,5 @@
const DocumentDetailPage = async () => {
return <div>document detail page</div>;
};
export default DocumentDetailPage;
@@ -3,14 +3,23 @@
import { useState } from "react";
import Modal from "@/components/shared/modal";
import Uploader from "@/components/ui/uploader";
import { useRouter } from "next/navigation";
import { api } from "@/trpc/react";
type DocumentUploadModalProps = {
trigger: React.ReactNode;
companyPublicId: string;
};
const DocumentUploadModal = ({ trigger }: DocumentUploadModalProps) => {
const DocumentUploadModal = ({
trigger,
companyPublicId,
}: DocumentUploadModalProps) => {
const router = useRouter();
const [open, setOpen] = useState(false);
const { mutateAsync } = api.document.create.useMutation();
return (
<Modal
title="Upload a document"
@@ -23,7 +32,19 @@ const DocumentUploadModal = ({ trigger }: DocumentUploadModalProps) => {
},
}}
>
<Uploader setOpen={setOpen} />
<Uploader
identifier={companyPublicId}
keySuffix="generic-document"
onSuccess={async (uploadedData) => {
await mutateAsync({
name: uploadedData.name,
bucketId: uploadedData.id,
});
router.refresh();
setOpen(false);
}}
/>
</Modal>
);
};
@@ -1,33 +1,16 @@
import { db } from "@/server/db";
import DocumentsTable from "./table";
import DocumentUploadModal from "./modal";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { withServerSession } from "@/server/auth";
import type { Document, User } from "@prisma/client";
import EmptyState from "@/components/shared/empty-state";
import { RiUploadCloudLine, RiAddFill } from "@remixicon/react";
export interface DocumentType extends Document {
uploadedBy: User | null;
}
const getDocuments = async (companyId: string) => {
return await db.document.findMany({
where: { companyId },
include: {
uploadedBy: true,
},
orderBy: {
createdAt: "desc",
},
});
};
import { api } from "@/trpc/server";
import { withServerSession } from "@/server/auth";
const DocumentsPage = async () => {
const documents = await api.document.getAll.query();
const session = await withServerSession();
const companyId = session?.user?.companyId;
const documents: DocumentType[] = await getDocuments(companyId);
if (documents.length === 0) {
return (
@@ -37,6 +20,7 @@ const DocumentsPage = async () => {
subtitle="Please click the button below to upload a new document."
>
<DocumentUploadModal
companyPublicId={session.user.companyPublicId}
trigger={
<Button size="lg">
<RiAddFill className="mr-2 h-5 w-5" />
@@ -60,6 +44,7 @@ const DocumentsPage = async () => {
<div>
<DocumentUploadModal
companyPublicId={session.user.companyPublicId}
trigger={
<Button>
<RiAddFill className="mr-2 h-5 w-5" />
@@ -2,7 +2,6 @@
import { dayjsExt } from "@/common/dayjs";
import { Card } from "@/components/ui/card";
import type { Document, User } from "@prisma/client";
import FileIcon from "@/components/shared/file-icon";
import { RiFileDownloadLine } from "@remixicon/react";
import { getPresignedGetUrl } from "@/server/file-uploads";
@@ -15,13 +14,12 @@ import {
TableHead,
TableHeader,
} from "@/components/ui/table";
import { type RouterOutputs } from "@/trpc/shared";
interface DocumentType extends Document {
uploadedBy: User | null;
}
type DocumentType = RouterOutputs["document"]["getAll"];
type DocumentTableProps = {
documents: DocumentType[];
documents: DocumentType;
};
const DocumentsTable = ({ documents }: DocumentTableProps) => {
@@ -46,15 +44,15 @@ const DocumentsTable = ({ documents }: DocumentTableProps) => {
{documents.map((document) => (
<TableRow key={document.id}>
<TableCell className="flex items-center ">
<FileIcon type={document.type} />
<FileIcon type={document.bucket.mimeType} />
<span className="flex">{document.name}</span>
</TableCell>
<TableCell>{document.uploadedBy?.name}</TableCell>
<TableCell>{document.uploader.user.name}</TableCell>
<TableCell>{dayjsExt().to(document.createdAt)}</TableCell>
<TableCell>
<button
onClick={async () => {
await openFileOnTab(document.key);
await openFileOnTab(document.bucket.key);
}}
className="cursor-pointer text-muted-foreground hover:text-primary/80"
>
@@ -1,5 +1,17 @@
const EquityPage = () => {
return <div>Equity page</div>;
import { PageLayout } from "@/components/dashboard/page-layout";
import { EquityDocumentUpload } from "@/components/equity/equity-document-upload";
import { withServerSession } from "@/server/auth";
const EquityPage = async () => {
const session = await withServerSession();
const user = session.user;
return (
<PageLayout
title="Equity"
action={<EquityDocumentUpload companyPublicId={user.companyPublicId} />}
/>
);
};
export default EquityPage;
@@ -0,0 +1,29 @@
import { api } from "@/trpc/server";
import { Card } from "@/components/ui/card";
import PdfViewer from "@/components/ui/pdf-viewer";
const TemplateDetailPage = async ({
params,
}: {
params: { templatePublicId: string };
}) => {
const { url } = await api.template.get.query({
publicId: params.templatePublicId,
});
return (
<div className="grid grid-cols-12">
<div className="relative col-span-12 h-full lg:col-span-6 xl:col-span-4">
<Card className="fixed h-fit w-full max-w-[300px]">
{/* Input fields */}
<div className="h-[500px]">Sidebar</div>
</Card>
</div>
<div className="col-span-12 lg:col-span-6 xl:col-span-8">
<PdfViewer file={url} />
</div>
</div>
);
};
export default TemplateDetailPage;
+13 -5
View File
@@ -3,8 +3,6 @@ import {
type getPresignedUrlOptions,
} from "@/server/file-uploads";
import { type DocumentMutationType } from "@/trpc/routers/document-router/schema";
/**
* usage
* ```js
@@ -21,12 +19,15 @@ import { type DocumentMutationType } from "@/trpc/routers/document-router/schema
*/
export const uploadFile = async (
file: File,
options?: Pick<getPresignedUrlOptions, "expiresIn" | "keyPrefix">,
options: Pick<
getPresignedUrlOptions,
"expiresIn" | "keySuffix" | "identifier"
>,
) => {
const { url, key } = await getPresignedPutUrl({
contentType: file.type,
fileName: file.name,
...(options && { ...options }),
...options,
});
const body = await file.arrayBuffer();
@@ -46,5 +47,12 @@ export const uploadFile = async (
}
const { name, type, size } = file;
return { key, name, type, size } as DocumentMutationType;
return {
key,
name,
mimeType: type,
size,
};
};
export type TUploadFile = Awaited<ReturnType<typeof uploadFile>>;
@@ -0,0 +1,32 @@
import { type ReactNode } from "react";
interface PageLayoutProps {
title: string;
description?: string;
action?: ReactNode;
children?: ReactNode;
}
export function PageLayout({
action,
children,
title,
description,
}: PageLayoutProps) {
return (
<div className="flex flex-col gap-y-3">
<div className="flex items-center justify-between gap-y-3 ">
<div className="gap-y-3">
<h3 className="font-medium">{title}</h3>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
<div>{action}</div>
</div>
{children}
</div>
);
}
@@ -0,0 +1,45 @@
"use client";
import { useRouter } from "next/navigation";
import Modal from "../shared/modal";
import { Button } from "../ui/button";
import { Uploader } from "../ui/uploader";
import { api } from "@/trpc/react";
interface EquityDocumentUploadProps {
companyPublicId: string;
}
export function EquityDocumentUpload({
companyPublicId,
}: EquityDocumentUploadProps) {
const router = useRouter();
const { mutateAsync } = api.template.create.useMutation();
return (
<Modal
title="Upload equity document"
subtitle=""
trigger={<Button>Upload Document</Button>}
>
<Uploader
identifier={companyPublicId}
keySuffix="equity-doc-uploads"
onSuccess={async (bucketData) => {
const equityTemplate = await mutateAsync({
bucketId: bucketData.id,
name: bucketData.name,
});
router.push(
`/${companyPublicId}/templates/${equityTemplate.publicId}`,
);
}}
accept={{
"application/pdf": [".pdf"],
}}
/>
</Modal>
);
}
+70
View File
@@ -0,0 +1,70 @@
"use client";
import { pdfjs, Document, Page } from "react-pdf";
import "react-pdf/dist/esm/Page/AnnotationLayer.css";
import "react-pdf/dist/esm/Page/TextLayer.css";
import { useResizeObserver } from "@wojtekmaj/react-hooks";
import { useCallback, useState } from "react";
pdfjs.GlobalWorkerOptions.workerSrc = `/pdf.worker.min.js`;
const options = {
cMapUrl: "/cmaps/",
standardFontDataUrl: "/standard_fonts/",
};
const resizeObserverOptions = {};
const maxWidth = 800;
interface PdfViewerProps {
file: string | File | null;
}
const PdfViewer = ({ file }: PdfViewerProps) => {
const [numPages, setNumPages] = useState<number>();
const [containerRef, setContainerRef] = useState<HTMLElement | null>(null);
const [containerWidth, setContainerWidth] = useState<number>();
const onResize = useCallback<ResizeObserverCallback>((entries) => {
const [entry] = entries;
if (entry) {
setContainerWidth(entry.contentRect.width);
}
}, []);
useResizeObserver(containerRef, resizeObserverOptions, onResize);
const onDocumentLoadSuccess = (event: {
numPages?: number;
document?: {
numPages: number;
};
}): void => {
const nextNumPages = event.numPages ?? event.document?.numPages;
setNumPages(nextNumPages);
};
return (
<div className="w-full max-w-[calc(100%-2em)]" ref={setContainerRef}>
<Document
file={file}
onLoadSuccess={onDocumentLoadSuccess}
options={options}
>
{Array.from(new Array(numPages), (el, index) => (
<Page
key={`page_${index + 1}`}
pageNumber={index + 1}
width={
containerWidth ? Math.min(containerWidth, maxWidth) : maxWidth
}
/>
))}
</Document>
</div>
);
};
export default PdfViewer;
+53 -32
View File
@@ -1,45 +1,67 @@
"use client";
import { Button } from "./button";
import { api } from "@/trpc/react";
import { useSession } from "next-auth/react";
import { uploadFile } from "@/common/uploads";
import { useRouter } from "next/navigation";
import React, { useState, useCallback } from "react";
import { useToast } from "@/components/ui/use-toast";
import { useDropzone, type FileWithPath } from "react-dropzone";
import {
useDropzone,
type FileWithPath,
type DropzoneOptions,
} from "react-dropzone";
type UploadProps = {
import { type RouterOutputs } from "@/trpc/shared";
type UploadReturn = RouterOutputs["bucket"]["create"];
type DocumentUploadDropzone = Omit<
DropzoneOptions,
"noClick" | "noKeyboard" | "onDrop"
>;
type UploaderProps = {
header?: React.ReactNode;
setOpen?: (val: boolean) => void;
uploadType?: "avatar" | "document";
};
function Uploader({ header, setOpen }: UploadProps) {
const router = useRouter();
// should be companyPublicId or membershipId or userId
identifier: string;
keySuffix: string;
onSuccess?: (data: UploadReturn) => void | Promise<void>;
} & DocumentUploadDropzone;
export function Uploader({
header,
identifier,
keySuffix,
onSuccess,
...rest
}: UploaderProps) {
const { toast } = useToast();
const { data: session } = useSession();
const [uploading, setUploading] = useState(false);
const publicId = session?.user.companyPublicId;
const uploader = api.document.create.useMutation();
const { mutateAsync } = api.bucket.create.useMutation();
const processFiles = async (acceptedFiles: FileWithPath[]) => {
for (const file of acceptedFiles) {
const upload = await uploadFile(file, { keyPrefix: publicId });
await uploader.mutateAsync(upload);
}
};
const uploadFiles = async (acceptedFiles: FileWithPath[]) => {
const onDrop = useCallback(async (acceptedFiles: FileWithPath[]) => {
try {
setUploading(true);
await processFiles(acceptedFiles);
router.refresh();
setOpen && setOpen(false);
toast({
variant: "default",
title: "🎉 Successfully uploaded",
description: "Your document(s) has been uploaded.",
});
for (const file of acceptedFiles) {
const upload = await uploadFile(file, { identifier, keySuffix });
const data = await mutateAsync({ ...upload });
if (onSuccess) {
await onSuccess(data);
}
toast({
variant: "default",
title: "🎉 Successfully uploaded",
description: "Your document(s) has been uploaded.",
});
}
} catch (error) {
console.error("Error uploading file:", error);
toast({
@@ -50,16 +72,15 @@ function Uploader({ header, setOpen }: UploadProps) {
} finally {
setUploading(false);
}
};
const onDrop = useCallback(async (acceptedFiles: FileWithPath[]) => {
await uploadFiles(acceptedFiles);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const { getRootProps, getInputProps, open } = useDropzone({
...rest,
// Disable click and keydown behavior
noClick: true,
noKeyboard: true,
// eslint-disable-next-line @typescript-eslint/no-misused-promises
onDrop,
});
+7 -4
View File
@@ -36,7 +36,9 @@ export interface getPresignedUrlOptions {
contentType: string;
expiresIn?: number;
fileName: string;
keyPrefix?: string;
keySuffix: string;
// should be companyPublicId or membershipId or userId
identifier: string;
}
const TEN_MINUTES_IN_SECONDS = 10 * 60;
@@ -45,12 +47,13 @@ export const getPresignedPutUrl = async ({
contentType,
expiresIn,
fileName,
keyPrefix,
keySuffix,
identifier,
}: getPresignedUrlOptions) => {
const { name, ext } = path.parse(fileName);
const Key = `${keyPrefix ?? customId(12)}/${slugify(name)}-${customId(
6,
const Key = `${identifier}/${keySuffix}/${customId(12)}/${slugify(
name,
)}${ext}`;
const putObjectCommand = new PutObjectCommand({
+4
View File
@@ -7,6 +7,8 @@ import { documentRouter } from "../routers/document-router/router";
import { waitListRouter } from "../routers/waitlist-router/router";
import { onboardingRouter } from "../routers/onboarding-router/router";
import { stakeholderRouter } from "../routers/stakeholder-router/router";
import { bucketRouter } from "../routers/bucket-router/router";
import { templateRouter } from "../routers/template-router/router";
/**
* This is the primary router for your server.
@@ -22,6 +24,8 @@ export const appRouter = createTRPCRouter({
shareClass: shareClassRouter,
equityPlan: equityPlanRouter,
stakeholder: stakeholderRouter,
bucket: bucketRouter,
template: templateRouter,
});
// export type definition of API
@@ -0,0 +1,8 @@
import { protectedProcedure } from "@/trpc/api/trpc";
import { ZodCreateBucketMutationSchema } from "../schema";
export const createBucketProcedure = protectedProcedure
.input(ZodCreateBucketMutationSchema)
.mutation(async ({ ctx, input }) => {
return ctx.db.bucket.create({ data: input });
});
+6
View File
@@ -0,0 +1,6 @@
import { createTRPCRouter } from "@/trpc/api/trpc";
import { createBucketProcedure } from "./procedures/create-bucket";
export const bucketRouter = createTRPCRouter({
create: createBucketProcedure,
});
+8
View File
@@ -0,0 +1,8 @@
import { z } from "zod";
export const ZodCreateBucketMutationSchema = z.object({
name: z.string(),
key: z.string(),
mimeType: z.string(),
size: z.number(),
});
@@ -0,0 +1,21 @@
import { protectedProcedure } from "@/trpc/api/trpc";
import { ZodCreateDocumentMutationSchema } from "../schema";
import { generatePublicId } from "@/common/id";
export const createDocumentProcedure = protectedProcedure
.input(ZodCreateDocumentMutationSchema)
.mutation(async ({ ctx, input }) => {
const user = ctx.session.user;
const publicId = generatePublicId();
const document = await ctx.db.document.create({
data: {
companyId: user.companyId,
uploaderId: user.membershipId,
publicId,
...input,
},
});
return document;
});
@@ -0,0 +1,37 @@
import { protectedProcedure } from "@/trpc/api/trpc";
export const getAllDocumentsProcedure = protectedProcedure.query(
async ({ ctx }) => {
const user = ctx.session.user;
const data = await ctx.db.document.findMany({
where: {
companyId: user.companyId,
},
include: {
uploader: {
select: {
user: {
select: {
name: true,
},
},
},
},
bucket: {
select: {
key: true,
mimeType: true,
size: true,
name: true,
},
},
},
orderBy: {
createdAt: "desc",
},
});
return data;
},
);
@@ -0,0 +1,25 @@
import { protectedProcedure } from "@/trpc/api/trpc";
import { ZodGetDocumentQuerySchema } from "../schema";
import { getPresignedGetUrl } from "@/server/file-uploads";
export const getDocumentProcedure = protectedProcedure
.input(ZodGetDocumentQuerySchema)
.query(async ({ ctx, input }) => {
const user = ctx.session.user;
const data = await ctx.db.document.findFirstOrThrow({
where: {
publicId: input.publicId,
companyId: user.companyId,
},
select: {
bucket: {
select: {
key: true,
},
},
},
});
return getPresignedGetUrl(data.bucket.key);
});
+7 -19
View File
@@ -1,22 +1,10 @@
import { createTRPCRouter, adminOnlyProcedure } from "@/trpc/api/trpc";
import { DocumentMutationSchema } from "./schema";
import { createTRPCRouter } from "@/trpc/api/trpc";
import { createDocumentProcedure } from "./procedures/create-document";
import { getDocumentProcedure } from "./procedures/get-document";
import { getAllDocumentsProcedure } from "./procedures/get-all-documents";
export const documentRouter = createTRPCRouter({
create: adminOnlyProcedure
.input(DocumentMutationSchema)
.mutation(async ({ ctx, input }) => {
const { db, session } = ctx;
const uploadedById = session.user.id;
const companyId = session.user.companyId;
await db.document.create({
data: {
...input,
uploadedById,
companyId,
},
});
return { success: true };
}),
create: createDocumentProcedure,
get: getDocumentProcedure,
getAll: getAllDocumentsProcedure,
});
+13 -5
View File
@@ -1,10 +1,18 @@
import { z } from "zod";
export const DocumentMutationSchema = z.object({
key: z.string(),
export const ZodCreateDocumentMutationSchema = z.object({
name: z.string(),
type: z.string(),
size: z.number(),
bucketId: z.string(),
});
export type DocumentMutationType = z.infer<typeof DocumentMutationSchema>;
export type TypeZodCreateDocumentMutationSchema = z.infer<
typeof ZodCreateDocumentMutationSchema
>;
export const ZodGetDocumentQuerySchema = z.object({
publicId: z.string(),
});
export type TypeZodGetDocumentQuerySchema = z.infer<
typeof ZodGetDocumentQuerySchema
>;
@@ -0,0 +1,24 @@
import { protectedProcedure } from "@/trpc/api/trpc";
import { ZodCreateTemplateMutationSchema } from "../schema";
import { generatePublicId } from "@/common/id";
export const createTemplateProcedure = protectedProcedure
.input(ZodCreateTemplateMutationSchema)
.mutation(async ({ ctx, input }) => {
const publicId = generatePublicId();
const user = ctx.session.user;
return ctx.db.template.create({
data: {
status: "draft",
publicId,
companyId: user.companyId,
uploaderId: user.id,
...input,
},
select: {
publicId: true,
name: true,
},
});
});
@@ -0,0 +1,25 @@
import { protectedProcedure } from "@/trpc/api/trpc";
import { ZodGetTemplateQuerySchema } from "../schema";
import { getPresignedGetUrl } from "@/server/file-uploads";
export const getTemplateProcedure = protectedProcedure
.input(ZodGetTemplateQuerySchema)
.query(async ({ ctx, input }) => {
const user = ctx.session.user;
const data = await ctx.db.template.findFirstOrThrow({
where: {
publicId: input.publicId,
companyId: user.companyId,
},
select: {
bucket: {
select: {
key: true,
},
},
},
});
return getPresignedGetUrl(data.bucket.key);
});
@@ -0,0 +1,8 @@
import { createTRPCRouter } from "@/trpc/api/trpc";
import { createTemplateProcedure } from "./procedures/create-template";
import { getTemplateProcedure } from "./procedures/get-template";
export const templateRouter = createTRPCRouter({
create: createTemplateProcedure,
get: getTemplateProcedure,
});
@@ -0,0 +1,10 @@
import { z } from "zod";
export const ZodCreateTemplateMutationSchema = z.object({
name: z.string(),
bucketId: z.string(),
});
export const ZodGetTemplateQuerySchema = z.object({
publicId: z.string(),
});