mirror of
https://github.com/documenso/documenso.git
synced 2025-11-12 15:53:02 +10:00
feat: persist fields and recipients for document editing
This commit is contained in:
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
19
packages/lib/server-only/field/get-fields-for-document.ts
Normal file
19
packages/lib/server-only/field/get-fields-for-document.ts
Normal 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;
|
||||
};
|
||||
127
packages/lib/server-only/field/set-fields-for-document.ts
Normal file
127
packages/lib/server-only/field/set-fields-for-document.ts
Normal 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;
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "FieldType" ADD VALUE 'NAME';
|
||||
@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "FieldType" ADD VALUE 'EMAIL';
|
||||
@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Field" ADD COLUMN "height" INTEGER NOT NULL DEFAULT -1,
|
||||
ADD COLUMN "width" INTEGER NOT NULL DEFAULT -1;
|
||||
@ -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");
|
||||
@ -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);
|
||||
@ -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)
|
||||
|
||||
@ -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",
|
||||
|
||||
55
packages/trpc/server/document-router/router.ts
Normal file
55
packages/trpc/server/document-router/router.ts
Normal 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.',
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
38
packages/trpc/server/document-router/schema.ts
Normal file
38
packages/trpc/server/document-router/schema.ts
Normal 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
|
||||
>;
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user