Skip to content

Commit 02aceb1

Browse files
authored
feat: dataroom share feature (#286)
* feat: some changes on share flow * feat: render contact and recipient's avatars * chore: update badge color * feat: update Share to ShareModal * feat: use common router * feat: create data room recipients records * feat: reload page on success * fix: modal input ring * feat: getting started with async dataroom email job * feat: getting started with document share page layout * feat: render DataRoomFileExplorer on shared dataroom * feat: shorten url with (document) * feat: rendering dataroom preview page (layout) as well * feat: rename folder structure * feat: cleaning up env variables * feat: remove access to dataroom * feat: cleaning up share/access modal * feat: send a link shared data room with jwt token * feat: return a share link that can be copied on share modal * feat: secure shared dataroom page * feat: securing dataroom file preview page as well * chore: export/import ExtendedRecipientType
1 parent 0fe5cfd commit 02aceb1

File tree

54 files changed

+1099
-434
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

54 files changed

+1099
-434
lines changed

.env.example

+5-1
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,11 @@
99
# When adding additional environment variables, the schema in "/src/env.js"
1010
# should be updated accordingly.
1111

12+
# Next.js environment variables
1213
NODE_ENV="development"
1314
NEXT_PUBLIC_NODE_ENV="development"
15+
BASE_URL="http://localhost:3000"
16+
NEXT_PUBLIC_BASE_URL="http://localhost:3000"
1417

1518
# Next.js collects completely anonymous telemetry data about general usage. Learn more here: https://nextjs.org/telemetry
1619
# Uncomment the following line to disable telemetry at run time
@@ -22,8 +25,9 @@ DATABASE_URL="postgres://captable:[email protected]:54321/captable"
2225

2326
# Next Auth
2427
# You can generate a new secret on the command line with:
25-
# openssl rand -base64 32
2628
# https://next-auth.js.org/configuration/options#secret
29+
30+
# openssl rand -base64 32
2731
NEXTAUTH_SECRET="xxxxxxxxxx"
2832
NEXTAUTH_URL="http://localhost:3000"
2933

.gitpod.yml

+2-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ tasks:
33
yarn &&
44
cp .env.example .env &&
55
export NEXTAUTH_SECRET="$(openssl rand -base64 32)" &&
6-
export NEXTAUTH_URL="$(gp url 3000)" &&
6+
export BASE_URL="$(gp url 3000)" &&
7+
export NEXT_PUBLIC_BASE_URL="$(gp url 3000)" &&
78
export EMAIL_SERVER_PORT=2500 &&
89
yarn docker:start &&
910
npx prisma migrate dev
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/*
2+
Warnings:
3+
4+
- You are about to drop the column `expiresAt` on the `DataRoomRecipient` table. All the data in the column will be lost.
5+
- A unique constraint covering the columns `[dataRoomId,email]` on the table `DataRoomRecipient` will be added. If there are existing duplicate values, this will fail.
6+
- Added the required column `name` to the `DataRoomRecipient` table without a default value. This is not possible if the table is not empty.
7+
8+
*/
9+
-- AlterTable
10+
ALTER TABLE "DataRoomRecipient" DROP COLUMN "expiresAt",
11+
ADD COLUMN "name" TEXT NOT NULL;
12+
13+
-- CreateIndex
14+
CREATE UNIQUE INDEX "DataRoomRecipient_dataRoomId_email_key" ON "DataRoomRecipient"("dataRoomId", "email");
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- AlterTable
2+
ALTER TABLE "DataRoomRecipient" ALTER COLUMN "name" DROP NOT NULL;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- CreateIndex
2+
CREATE INDEX "DataRoom_publicId_idx" ON "DataRoom"("publicId");
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- CreateIndex
2+
CREATE INDEX "DataRoomRecipient_id_dataRoomId_idx" ON "DataRoomRecipient"("id", "dataRoomId");

prisma/schema.prisma

+7-4
Original file line numberDiff line numberDiff line change
@@ -367,8 +367,9 @@ model DataRoom {
367367
createdAt DateTime @default(now())
368368
updatedAt DateTime @updatedAt
369369
370-
@@unique([companyId, name])
371370
@@unique([publicId])
371+
@@unique([companyId, name])
372+
@@index([publicId])
372373
@@index([companyId])
373374
}
374375

@@ -388,6 +389,7 @@ model DataRoomDocument {
388389

389390
model DataRoomRecipient {
390391
id String @id @default(cuid())
392+
name String?
391393
email String
392394
dataRoomId String
393395
dataRoom DataRoom @relation(fields: [dataRoomId], references: [id], onDelete: Cascade)
@@ -398,10 +400,11 @@ model DataRoomRecipient {
398400
stakeholderId String?
399401
stakeholder Stakeholder? @relation(fields: [stakeholderId], references: [id], onDelete: SetNull)
400402
401-
createdAt DateTime @default(now())
402-
updatedAt DateTime @updatedAt
403-
expiresAt DateTime?
403+
createdAt DateTime @default(now())
404+
updatedAt DateTime @updatedAt
404405
406+
@@unique([dataRoomId, email])
407+
@@index([id, dataRoomId])
405408
@@index([memberId])
406409
@@index([dataRoomId])
407410
@@index([stakeholderId])

prisma/seeds/team.ts

+1
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ const seedTeam = async () => {
7272
name,
7373
email,
7474
password: hashedPassword,
75+
emailVerified: new Date(),
7576
// image,
7677
},
7778
});

src/app/(authenticated)/(dashboard)/[publicId]/documents/data-rooms/[dataRoomPublicId]/page.tsx

+15-8
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
"use server";
22

3-
import { getServerAuthSession } from "@/server/auth";
3+
import { type ExtendedRecipientType } from "@/components/common/share-modal";
44
import { api } from "@/trpc/server";
5+
import type { Bucket, DataRoom } from "@prisma/client";
56
import { notFound } from "next/navigation";
67
import DataRoomFiles from "../components/data-room-files";
78

@@ -10,21 +11,27 @@ const DataRoomSettinsPage = async ({
1011
}: {
1112
params: { publicId: string; dataRoomPublicId: string };
1213
}) => {
13-
const session = await getServerAuthSession();
14-
const { dataRoom, documents } = await api.dataRoom.getDataRoom.query({
15-
dataRoomPublicId,
16-
});
17-
const contacts = await api.dataRoom.getContacts.query();
14+
const { dataRoom, documents, recipients } =
15+
await api.dataRoom.getDataRoom.query({
16+
dataRoomPublicId,
17+
include: {
18+
company: false,
19+
recipients: true,
20+
documents: true,
21+
},
22+
});
23+
const contacts = await api.common.getContacts.query();
1824

1925
if (!dataRoom) {
2026
return notFound();
2127
}
2228

2329
return (
2430
<DataRoomFiles
25-
dataRoom={dataRoom}
2631
contacts={contacts}
27-
documents={documents}
32+
dataRoom={dataRoom as DataRoom}
33+
recipients={recipients as ExtendedRecipientType[]}
34+
documents={documents as Bucket[]}
2835
companyPublicId={publicId}
2936
/>
3037
);

src/app/(authenticated)/(dashboard)/[publicId]/documents/data-rooms/components/data-room-files.tsx

+70-4
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,23 @@
11
"use client";
22

33
import EmptyState from "@/components/common/empty-state";
4-
import ShareModal from "@/components/common/share";
4+
import ShareModal, {
5+
type ExtendedRecipientType,
6+
} from "@/components/common/share-modal";
57
import DataRoomFileExplorer from "@/components/documents/data-room/explorer";
68
import { Button } from "@/components/ui/button";
9+
import { useToast } from "@/components/ui/use-toast";
710
import { api } from "@/trpc/react";
11+
import type { DataRoomRecipientType } from "@/trpc/routers/data-room-router/schema";
812
import { type ContactsType } from "@/types/contacts";
913
import type { Bucket, DataRoom } from "@prisma/client";
1014
import { RiShareLine } from "@remixicon/react";
15+
import { useRouter } from "next/navigation";
1116
import { useDebounceCallback } from "usehooks-ts";
1217

1318
import {
1419
RiFolder3Fill as FolderIcon,
1520
RiAddFill,
16-
// RiShareLine,
1721
RiUploadCloudLine,
1822
} from "@remixicon/react";
1923
import Link from "next/link";
@@ -31,6 +35,7 @@ interface DataRoomType extends DataRoom {
3135
type DataRoomFilesProps = {
3236
dataRoom: DataRoom;
3337
documents: Bucket[];
38+
recipients: ExtendedRecipientType[];
3439
companyPublicId: string;
3540
contacts: ContactsType;
3641
};
@@ -39,11 +44,56 @@ const DataRoomFiles = ({
3944
dataRoom,
4045
documents,
4146
contacts,
47+
recipients,
4248
companyPublicId,
4349
}: DataRoomFilesProps) => {
44-
const { mutateAsync } = api.dataRoom.save.useMutation();
50+
const router = useRouter();
51+
const { toast } = useToast();
52+
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL;
53+
const { mutateAsync: saveDataRoomMutation } = api.dataRoom.save.useMutation();
54+
const { mutateAsync: shareDataRoomMutation } = api.dataRoom.share.useMutation(
55+
{
56+
onSuccess: () => {
57+
router.refresh();
58+
59+
toast({
60+
title: "✨ Complete",
61+
description: "Data room successfully shared.",
62+
});
63+
},
64+
65+
onError: (error) => {
66+
toast({
67+
title: error.message,
68+
description: "",
69+
variant: "destructive",
70+
});
71+
},
72+
},
73+
);
74+
75+
const { mutateAsync: unShareDataRoomMutation } =
76+
api.dataRoom.unShare.useMutation({
77+
onSuccess: () => {
78+
router.refresh();
79+
80+
toast({
81+
title: "✨ Complete",
82+
description: "Successfully removed access to data room.",
83+
});
84+
},
85+
86+
onError: (error) => {
87+
toast({
88+
title: error.message,
89+
description: "",
90+
variant: "destructive",
91+
});
92+
},
93+
});
94+
4595
const debounced = useDebounceCallback(async (name) => {
46-
await mutateAsync({
96+
await saveDataRoomMutation({
4797
name,
4898
publicId: dataRoom.publicId,
4999
});
@@ -84,9 +134,25 @@ const DataRoomFiles = ({
84134
{documents.length > 0 && (
85135
<div className="flex gap-3">
86136
<ShareModal
137+
recipients={recipients}
87138
contacts={contacts}
139+
baseLink={`${baseUrl}/data-rooms/${dataRoom.publicId}`}
88140
title={`Share data room - "${dataRoom.name}"`}
89141
subtitle="Share this data room with others."
142+
onShare={async ({ selectedContacts, others }) => {
143+
await shareDataRoomMutation({
144+
dataRoomId: dataRoom.id,
145+
selectedContacts:
146+
selectedContacts as DataRoomRecipientType[],
147+
others: others as DataRoomRecipientType[],
148+
});
149+
}}
150+
removeAccess={async ({ recipientId }) => {
151+
await unShareDataRoomMutation({
152+
dataRoomId: dataRoom.id,
153+
recipientId,
154+
});
155+
}}
90156
trigger={
91157
<Button variant={"outline"}>
92158
<RiShareLine className="mr-2 h-5 w-5" />

src/app/(authenticated)/(dashboard)/[publicId]/documents/esign/components/table.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ export const ESignTable = ({ documents, companyPublicId }: ESignTableProps) => {
5757
{item.status === "DRAFT" && (
5858
<Link
5959
className={buttonVariants()}
60-
href={`/${companyPublicId}/documents/esign/${item.publicId}`}
60+
href={`/${companyPublicId}/esign/${item.publicId}`}
6161
>
6262
Edit
6363
</Link>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
"use server";
2+
3+
import { SharePageLayout } from "@/components/share/page-layout";
4+
import { decode, type JWTVerifyResult } from "@/lib/jwt";
5+
import { db } from "@/server/db";
6+
import { RiFolder3Fill as FolderIcon } from "@remixicon/react";
7+
import Link from "next/link";
8+
import { notFound } from "next/navigation";
9+
10+
const DataRoomPage = async ({
11+
params: { publicId, bucketId },
12+
searchParams: { token },
13+
}: {
14+
params: { publicId: string; bucketId: string };
15+
searchParams: { token: string };
16+
}) => {
17+
let decodedToken: JWTVerifyResult | null = null;
18+
19+
try {
20+
decodedToken = await decode(token);
21+
} catch (error) {
22+
return notFound();
23+
}
24+
25+
const { companyId, dataRoomId, recipientId } = decodedToken?.payload;
26+
if (!companyId || !recipientId || !dataRoomId) {
27+
return notFound();
28+
}
29+
30+
const recipient = await db.dataRoomRecipient.findFirstOrThrow({
31+
where: {
32+
id: recipientId,
33+
dataRoomId,
34+
},
35+
36+
select: {
37+
id: true,
38+
},
39+
});
40+
41+
if (!recipient) {
42+
return notFound();
43+
}
44+
45+
const dataRoom = await db.dataRoom.findFirstOrThrow({
46+
where: {
47+
publicId,
48+
},
49+
50+
include: {
51+
company: true,
52+
documents: {
53+
include: {
54+
document: {
55+
include: {
56+
bucket: true,
57+
},
58+
},
59+
},
60+
},
61+
},
62+
});
63+
64+
const dataRoomFile = dataRoom.documents.find(
65+
(doc) => doc.document.bucket.id === bucketId,
66+
);
67+
68+
if (
69+
dataRoomId !== dataRoom.id ||
70+
dataRoom?.companyId !== companyId ||
71+
!dataRoomFile
72+
) {
73+
return notFound();
74+
}
75+
76+
const file = dataRoomFile?.document.bucket;
77+
78+
if (!file) {
79+
return notFound();
80+
}
81+
82+
const company = dataRoom.company;
83+
84+
return (
85+
<SharePageLayout
86+
medium="dataRoom"
87+
company={{
88+
name: company.name,
89+
logo: company.logo,
90+
}}
91+
title={
92+
<div className="flex">
93+
<FolderIcon
94+
className="mr-3 mt-1 h-6 w-6 text-primary/60"
95+
aria-hidden="true"
96+
/>
97+
98+
<h1 className="text-2xl font-semibold tracking-tight">
99+
<span className="text-primary/60">Data room / </span>
100+
<Link
101+
href={`/data-rooms/${publicId}?token=${token}`}
102+
className="text-primary/60 hover:text-primary/90 hover:underline"
103+
>
104+
{dataRoom.name}
105+
</Link>
106+
{` / ${file.name}`}
107+
</h1>
108+
</div>
109+
}
110+
>
111+
<div>File preview {file.name}</div>
112+
</SharePageLayout>
113+
);
114+
};
115+
116+
export default DataRoomPage;

0 commit comments

Comments
 (0)