feat: persist fields and recipients for document editing

This commit is contained in:
Mythie
2023-06-21 23:49:23 +10:00
parent 115cae8365
commit 3c73f030ac
28 changed files with 1432 additions and 113 deletions

View File

@ -31,7 +31,6 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
const user = await getUserByEmail({ email }).catch(() => null);
if (!user || !user.password) {
console.log('no user');
return null;
}

View File

@ -19,6 +19,7 @@
"@upstash/redis": "^1.20.6",
"bcrypt": "^5.1.0",
"pdf-lib": "^1.17.1",
"nanoid": "^4.0.2",
"next": "13.4.1",
"next-auth": "^4.22.1",
"stripe": "^12.7.0"

View File

@ -0,0 +1,19 @@
import { prisma } from '@documenso/prisma';
export interface GetFieldsForDocumentOptions {
documentId: number;
userId: number;
}
export const getFieldsForDocument = async ({ documentId, userId }: GetFieldsForDocumentOptions) => {
const fields = await prisma.field.findMany({
where: {
documentId,
Document: {
userId,
},
},
});
return fields;
};

View File

@ -0,0 +1,127 @@
import { prisma } from '@documenso/prisma';
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
export interface SetFieldsForDocumentOptions {
userId: number;
documentId: number;
fields: {
id?: number | null;
signerEmail: string;
pageNumber: number;
pageX: number;
pageY: number;
pageWidth: number;
pageHeight: number;
}[];
}
export const setFieldsForDocument = async ({
userId,
documentId,
fields,
}: SetFieldsForDocumentOptions) => {
const document = await prisma.document.findFirst({
where: {
id: documentId,
userId,
},
});
if (!document) {
throw new Error('Document not found');
}
const existingFields = await prisma.field.findMany({
where: {
documentId,
},
include: {
Recipient: true,
},
});
const removedFields = existingFields.filter(
(existingField) =>
!fields.find(
(field) =>
field.id === existingField.id || field.signerEmail === existingField.Recipient?.email,
),
);
const linkedFields = fields.map((field) => {
const existing = existingFields.find((existingField) => existingField.id === field.id);
return {
...field,
...existing,
};
});
for (const field of linkedFields) {
if (
field.Recipient?.sendStatus === SendStatus.SENT ||
field.Recipient?.signingStatus === SigningStatus.SIGNED
) {
throw new Error('Cannot modify fields after sending');
}
}
const persistedFields = await prisma.$transaction(
linkedFields.map((field) =>
field.id
? prisma.field.update({
where: {
id: field.id,
recipientId: field.recipientId,
documentId,
},
data: {
type: field.type,
page: field.pageNumber,
positionX: field.pageX,
positionY: field.pageY,
width: field.pageWidth,
height: field.pageHeight,
},
})
: prisma.field.create({
data: {
type: field.type!,
page: field.pageNumber,
positionX: field.pageX,
positionY: field.pageY,
width: field.pageWidth,
height: field.pageHeight,
customText: '',
inserted: false,
Document: {
connect: {
id: document.id,
},
},
Recipient: {
connect: {
documentId_email: {
documentId: document.id,
email: field.signerEmail,
},
},
},
},
}),
),
);
if (removedFields.length > 0) {
await prisma.field.deleteMany({
where: {
id: {
in: removedFields.map((field) => field.id),
},
},
});
}
return persistedFields;
};

View File

@ -0,0 +1,22 @@
import { prisma } from '@documenso/prisma';
export interface GetRecipientsForDocumentOptions {
documentId: number;
userId: number;
}
export const getRecipientsForDocument = async ({
documentId,
userId,
}: GetRecipientsForDocumentOptions) => {
const recipients = await prisma.recipient.findMany({
where: {
documentId,
Document: {
userId,
},
},
});
return recipients;
};

View File

@ -0,0 +1,103 @@
import { nanoid } from 'nanoid';
import { prisma } from '@documenso/prisma';
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
export interface SetRecipientsForDocumentOptions {
userId: number;
documentId: number;
recipients: {
id?: number | null;
email: string;
name: string;
}[];
}
export const setRecipientsForDocument = async ({
userId,
documentId,
recipients,
}: SetRecipientsForDocumentOptions) => {
const document = await prisma.document.findFirst({
where: {
id: documentId,
userId,
},
});
if (!document) {
throw new Error('Document not found');
}
const existingRecipients = await prisma.recipient.findMany({
where: {
documentId,
},
});
const removedRecipients = existingRecipients.filter(
(existingRecipient) =>
!recipients.find(
(recipient) =>
recipient.id === existingRecipient.id || recipient.email === existingRecipient.email,
),
);
const linkedRecipients = recipients.map((recipient) => {
const existing = existingRecipients.find(
(existingRecipient) =>
existingRecipient.id === recipient.id || existingRecipient.email === recipient.email,
);
return {
...recipient,
...existing,
};
});
for (const recipient of linkedRecipients) {
if (
recipient.sendStatus === SendStatus.SENT ||
recipient.signingStatus === SigningStatus.SIGNED
) {
throw new Error('Cannot modify recipients after sending');
}
}
const persistedRecipients = await prisma.$transaction(
linkedRecipients.map((recipient) =>
recipient.id
? prisma.recipient.update({
where: {
id: recipient.id,
documentId,
},
data: {
name: recipient.name,
email: recipient.email,
documentId,
},
})
: prisma.recipient.create({
data: {
name: recipient.name,
email: recipient.email,
token: nanoid(),
documentId,
},
}),
),
);
if (removedRecipients.length > 0) {
await prisma.recipient.deleteMany({
where: {
id: {
in: removedRecipients.map((recipient) => recipient.id),
},
},
});
}
return persistedRecipients;
};

View File

@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "FieldType" ADD VALUE 'NAME';

View File

@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "FieldType" ADD VALUE 'EMAIL';

View File

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Field" ADD COLUMN "height" INTEGER NOT NULL DEFAULT -1,
ADD COLUMN "width" INTEGER NOT NULL DEFAULT -1;

View File

@ -0,0 +1,8 @@
/*
Warnings:
- A unique constraint covering the columns `[documentId,email]` on the table `Recipient` will be added. If there are existing duplicate values, this will fail.
*/
-- CreateIndex
CREATE UNIQUE INDEX "Recipient_documentId_email_key" ON "Recipient"("documentId", "email");

View File

@ -0,0 +1,9 @@
-- AlterTable
ALTER TABLE "Field" ALTER COLUMN "positionX" SET DEFAULT 0,
ALTER COLUMN "positionX" SET DATA TYPE DECIMAL(65,30),
ALTER COLUMN "positionY" SET DEFAULT 0,
ALTER COLUMN "positionY" SET DATA TYPE DECIMAL(65,30),
ALTER COLUMN "height" SET DEFAULT -1,
ALTER COLUMN "height" SET DATA TYPE DECIMAL(65,30),
ALTER COLUMN "width" SET DEFAULT -1,
ALTER COLUMN "width" SET DATA TYPE DECIMAL(65,30);

View File

@ -1,5 +1,6 @@
generator client {
provider = "prisma-client-js"
previewFeatures = ["extendedWhereUnique"]
}
datasource db {
@ -124,11 +125,15 @@ model Recipient {
Document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
Field Field[]
Signature Signature[]
@@unique([documentId, email])
}
enum FieldType {
SIGNATURE
FREE_SIGNATURE
NAME
EMAIL
DATE
TEXT
}
@ -139,8 +144,10 @@ model Field {
recipientId Int?
type FieldType
page Int
positionX Int @default(0)
positionY Int @default(0)
positionX Decimal @default(0)
positionY Decimal @default(0)
width Decimal @default(-1)
height Decimal @default(-1)
customText String
inserted Boolean
Document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)

View File

@ -7,6 +7,8 @@
"scripts": {
},
"dependencies": {
"@documenso/lib": "*",
"@documenso/prisma": "*",
"@tanstack/react-query": "^4.29.5",
"@trpc/client": "^10.25.1",
"@trpc/next": "^10.25.1",

View File

@ -0,0 +1,55 @@
import { TRPCError } from '@trpc/server';
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document';
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
import { authenticatedProcedure, router } from '../trpc';
import {
ZSetFieldsForDocumentMutationSchema,
ZSetRecipientsForDocumentMutationSchema,
} from './schema';
export const documentRouter = router({
setRecipientsForDocument: authenticatedProcedure
.input(ZSetRecipientsForDocumentMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
const { documentId, recipients } = input;
return await setRecipientsForDocument({
userId: ctx.user.id,
documentId,
recipients,
});
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message:
'We were unable to set the recipients for this document. Please try again later.',
});
}
}),
setFieldsForDocument: authenticatedProcedure
.input(ZSetFieldsForDocumentMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
const { documentId, fields } = input;
return await setFieldsForDocument({
userId: ctx.user.id,
documentId,
fields,
});
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to set the fields for this document. Please try again later.',
});
}
}),
});

View File

@ -0,0 +1,38 @@
import { z } from 'zod';
import { FieldType } from '@documenso/prisma/client';
export const ZSetRecipientsForDocumentMutationSchema = z.object({
documentId: z.number(),
recipients: z.array(
z.object({
id: z.number().nullish(),
email: z.string().min(1).email(),
name: z.string(),
}),
),
});
export type TSetRecipientsForDocumentMutationSchema = z.infer<
typeof ZSetRecipientsForDocumentMutationSchema
>;
export const ZSetFieldsForDocumentMutationSchema = z.object({
documentId: z.number(),
fields: z.array(
z.object({
id: z.number().nullish(),
type: z.nativeEnum(FieldType),
signerEmail: z.string().min(1),
pageNumber: z.number().min(1),
pageX: z.number().min(0),
pageY: z.number().min(0),
pageWidth: z.number().min(0),
pageHeight: z.number().min(0),
}),
),
});
export type TSetFieldsForDocumentMutationSchema = z.infer<
typeof ZSetFieldsForDocumentMutationSchema
>;

View File

@ -1,4 +1,5 @@
import { authRouter } from './auth-router/router';
import { documentRouter } from './document-router/router';
import { profileRouter } from './profile-router/router';
import { procedure, router } from './trpc';
@ -6,6 +7,7 @@ export const appRouter = router({
hello: procedure.query(() => 'Hello, world!'),
auth: authRouter,
profile: profileRouter,
document: documentRouter,
});
export type AppRouter = typeof appRouter;

View File

@ -10,22 +10,10 @@ export type CardProps = React.HTMLAttributes<HTMLDivElement> & {
spotlight?: boolean;
gradient?: boolean;
degrees?: number;
lightMode?: boolean;
};
const Card = React.forwardRef<HTMLDivElement, CardProps>(
(
{
className,
children,
gradient = false,
spotlight = false,
degrees = 120,
lightMode = true,
...props
},
ref,
) => {
({ className, children, gradient = false, spotlight = false, degrees = 120, ...props }, ref) => {
const mouseX = useMotionValue(0);
const mouseY = useMotionValue(0);
@ -46,12 +34,15 @@ const Card = React.forwardRef<HTMLDivElement, CardProps>(
} as React.CSSProperties
}
className={cn(
'bg-background text-foreground dark:hover:border-documenso group relative rounded-lg border-2 backdrop-blur-[2px]',
'bg-background text-foreground group relative rounded-lg border-2 backdrop-blur-[2px]',
{
'gradient-border-mask before:pointer-events-none before:absolute before:-inset-[2px] before:rounded-lg before:p-[2px] before:[background:linear-gradient(var(--card-gradient-degrees),theme(colors.documenso.DEFAULT/50%)_5%,theme(colors.border/80%)_30%)]':
gradient && lightMode,
gradient,
'dark:gradient-border-mask before:pointer-events-none before:absolute before:-inset-[2px] before:rounded-lg before:p-[2px] before:[background:linear-gradient(var(--card-gradient-degrees),theme(colors.documenso.DEFAULT/70%)_5%,theme(colors.border/80%)_30%)]':
gradient,
'shadow-[0_0_0_4px_theme(colors.gray.100/70%),0_0_0_1px_theme(colors.gray.100/70%),0_0_0_0.5px_theme(colors.primary.DEFAULT/70%)]':
lightMode,
true,
'dark:shadow-[0]': true,
},
className,
)}