Merge branch 'main' into feat/public-profiles

This commit is contained in:
Lucas Smith
2024-06-27 12:10:45 +10:00
committed by GitHub
26 changed files with 463 additions and 130 deletions

View File

@ -292,7 +292,9 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
await unseedUser(user.id);
});
test('[DOCUMENT_FLOW]: should be able to create, send and sign a document', async ({ page }) => {
test('[DOCUMENT_FLOW]: should not be able to create a document without signatures', async ({
page,
}) => {
const user = await seedUser();
const document = await seedBlankDocument(user);
@ -323,43 +325,9 @@ test('[DOCUMENT_FLOW]: should be able to create, send and sign a document', asyn
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Continue' }).click();
// Add subject and send
await expect(page.getByRole('heading', { name: 'Add Subject' })).toBeVisible();
await page.getByRole('button', { name: 'Send' }).click();
await page.waitForURL('/documents');
// Assert document was created
await expect(page.getByRole('link', { name: documentTitle })).toBeVisible();
await page.getByRole('link', { name: documentTitle }).click();
await page.waitForURL(/\/documents\/\d+/);
// Start signing process
const url = page.url().split('/');
const documentId = url[url.length - 1];
const { token } = await getRecipientByEmail({
email: 'user1@example.com',
documentId: Number(documentId),
});
await page.goto(`/sign/${token}`);
await page.waitForURL(`/sign/${token}`);
// Check if document has been viewed
const { status } = await getDocumentByToken(token);
expect(status).toBe(DocumentStatus.PENDING);
await page.getByRole('button', { name: 'Complete' }).click();
await expect(page.getByRole('dialog').getByText('Complete Signing').first()).toBeVisible();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(`/sign/${token}/complete`);
await expect(page.getByText('Document Signed')).toBeVisible();
// Check if document has been signed
const { status: completedStatus } = await getDocumentByToken(token);
expect(completedStatus).toBe(DocumentStatus.COMPLETED);
await expect(
page.getByRole('dialog').getByText('No signature field found').first(),
).toBeVisible();
await unseedUser(user.id);
});
@ -449,6 +417,9 @@ test('[DOCUMENT_FLOW]: should be able to create, send with redirect url, sign a
await page.getByPlaceholder('Email').fill('user1@example.com');
await page.getByPlaceholder('Name').fill('User 1');
await page.getByRole('combobox').click();
await page.getByLabel('Needs to approve').getByText('Needs to approve').click();
await page.getByRole('button', { name: 'Continue' }).click();
// Add fields
@ -480,8 +451,8 @@ test('[DOCUMENT_FLOW]: should be able to create, send with redirect url, sign a
expect(status).toBe(DocumentStatus.PENDING);
await page.getByRole('button', { name: 'Complete' }).click();
await expect(page.getByRole('dialog').getByText('Complete Signing').first()).toBeVisible();
await page.getByRole('button', { name: 'Sign' }).click();
await expect(page.getByRole('dialog').getByText('Complete Approval').first()).toBeVisible();
await page.getByRole('button', { name: 'Approve' }).click();
await page.waitForURL('https://documenso.com');

View File

@ -6,5 +6,9 @@ import { getPricesByPlan } from './get-prices-by-plan';
* Returns the Stripe prices of items that affect the amount of documents a user can create.
*/
export const getDocumentRelatedPrices = async () => {
return await getPricesByPlan([STRIPE_PLAN_TYPE.REGULAR, STRIPE_PLAN_TYPE.COMMUNITY, STRIPE_PLAN_TYPE.ENTERPRISE]);
return await getPricesByPlan([
STRIPE_PLAN_TYPE.REGULAR,
STRIPE_PLAN_TYPE.COMMUNITY,
STRIPE_PLAN_TYPE.ENTERPRISE,
]);
};

View File

@ -1,7 +1,7 @@
import type Stripe from 'stripe';
import { stripe } from '@documenso/lib/server-only/stripe';
import type { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { stripe } from '@documenso/lib/server-only/stripe';
// Utility type to handle usage of the `expand` option.
type PriceWithProduct = Stripe.Price & { product: Stripe.Product };

View File

@ -6,5 +6,9 @@ import { getPricesByPlan } from './get-prices-by-plan';
* Returns the prices of items that count as the account's primary plan.
*/
export const getPrimaryAccountPlanPrices = async () => {
return await getPricesByPlan([STRIPE_PLAN_TYPE.REGULAR, STRIPE_PLAN_TYPE.COMMUNITY, STRIPE_PLAN_TYPE.ENTERPRISE]);
return await getPricesByPlan([
STRIPE_PLAN_TYPE.REGULAR,
STRIPE_PLAN_TYPE.COMMUNITY,
STRIPE_PLAN_TYPE.ENTERPRISE,
]);
};

View File

@ -1,5 +1,7 @@
import { DateTime } from 'luxon';
import { prisma } from '@documenso/prisma';
import { SubscriptionStatus } from '@documenso/prisma/client';
import { DocumentStatus, SubscriptionStatus } from '@documenso/prisma/client';
export const getUsersCount = async () => {
return await prisma.user.count();
@ -16,3 +18,65 @@ export const getUsersWithSubscriptionsCount = async () => {
},
});
};
export const getUserWithAtLeastOneDocumentPerMonth = async () => {
return await prisma.user.count({
where: {
Document: {
some: {
createdAt: {
gte: DateTime.now().minus({ months: 1 }).toJSDate(),
},
},
},
},
});
};
export const getUserWithAtLeastOneDocumentSignedPerMonth = async () => {
return await prisma.user.count({
where: {
Document: {
some: {
status: {
equals: DocumentStatus.COMPLETED,
},
completedAt: {
gte: DateTime.now().minus({ months: 1 }).toJSDate(),
},
},
},
},
});
};
export type GetUserWithDocumentMonthlyGrowth = Array<{
month: string;
count: number;
signed_count: number;
}>;
type GetUserWithDocumentMonthlyGrowthQueryResult = Array<{
month: Date;
count: bigint;
signed_count: bigint;
}>;
export const getUserWithSignedDocumentMonthlyGrowth = async () => {
const result = await prisma.$queryRaw<GetUserWithDocumentMonthlyGrowthQueryResult>`
SELECT
DATE_TRUNC('month', "Document"."createdAt") AS "month",
COUNT(DISTINCT "Document"."userId") as "count",
COUNT(DISTINCT CASE WHEN "Document"."status" = 'COMPLETED' THEN "Document"."userId" END) as "signed_count"
FROM "Document"
GROUP BY "month"
ORDER BY "month" DESC
LIMIT 12
`;
return result.map((row) => ({
month: DateTime.fromJSDate(row.month).toFormat('yyyy-MM'),
count: Number(row.count),
signed_count: Number(row.signed_count),
}));
};

View File

@ -5,13 +5,7 @@ import type { RequestMetadata } from '@documenso/lib/universal/extract-request-m
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import {
DocumentSource,
DocumentStatus,
RecipientRole,
SendStatus,
SigningStatus,
} from '@documenso/prisma/client';
import { DocumentStatus, RecipientRole, SendStatus, SigningStatus } from '@documenso/prisma/client';
import { WebhookTriggerEvents } from '@documenso/prisma/client';
import { jobsClient } from '../../jobs/client';
@ -71,8 +65,6 @@ export const sendDocument = async ({
},
});
const customEmail = document?.documentMeta;
if (!document) {
throw new Error('Document not found');
}
@ -87,8 +79,6 @@ export const sendDocument = async ({
const { documentData } = document;
const isDirectTemplate = document.source === DocumentSource.TEMPLATE_DIRECT_LINK;
if (!documentData.data) {
throw new Error('Document data not found');
}
@ -98,6 +88,7 @@ export const sendDocument = async ({
const prefilled = await insertFormValuesInPdf({
pdf: Buffer.from(file),
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
formValues: document.formValues as Record<string, string | number | boolean>,
});
@ -119,6 +110,31 @@ export const sendDocument = async ({
Object.assign(document, result);
}
// Commented out server side checks for minimum 1 signature per signer now since we need to
// decide if we want to enforce this for API & templates.
// const fields = await getFieldsForDocument({
// documentId: documentId,
// userId: userId,
// });
// const fieldsWithSignerEmail = fields.map((field) => ({
// ...field,
// signerEmail:
// document.Recipient.find((recipient) => recipient.id === field.recipientId)?.email ?? '',
// }));
// const everySignerHasSignature = document?.Recipient.every(
// (recipient) =>
// recipient.role !== RecipientRole.SIGNER ||
// fieldsWithSignerEmail.some(
// (field) => field.type === 'SIGNATURE' && field.signerEmail === recipient.email,
// ),
// );
// if (!everySignerHasSignature) {
// throw new Error('Some signers have not been assigned a signature field.');
// }
if (sendEmail) {
await Promise.all(
document.Recipient.map(async (recipient) => {

View File

@ -26,6 +26,7 @@ export const getCompletedFieldsForToken = async ({ token }: GetCompletedFieldsFo
select: {
name: true,
email: true,
signingStatus: true,
},
},
},

View File

@ -5,6 +5,8 @@ export interface GetFieldsForDocumentOptions {
userId: number;
}
export type DocumentField = Awaited<ReturnType<typeof getFieldsForDocument>>[number];
export const getFieldsForDocument = async ({ documentId, userId }: GetFieldsForDocumentOptions) => {
const fields = await prisma.field.findMany({
where: {
@ -26,6 +28,16 @@ export const getFieldsForDocument = async ({ documentId, userId }: GetFieldsForD
],
},
},
include: {
Signature: true,
Recipient: {
select: {
name: true,
email: true,
signingStatus: true,
},
},
},
orderBy: {
id: 'asc',
},

View File

@ -481,7 +481,9 @@ export const createDocumentFromDirectTemplate = async ({
const emailTemplate = createElement(DocumentCreatedFromDirectTemplateEmailTemplate, {
recipientName: directRecipientEmail,
recipientRole: directTemplateRecipient.role,
documentLink: `${formatDocumentsPath(document.team?.url)}/${document.id}`,
documentLink: `${NEXT_PUBLIC_WEBAPP_URL()}${formatDocumentsPath(document.team?.url)}/${
document.id
}`,
documentName: document.title,
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000',
});

View File

@ -354,6 +354,9 @@ export const seedPendingDocumentWithFullFields = async ({
...updateDocumentOptions,
status: DocumentStatus.PENDING,
},
include: {
documentMeta: true,
},
});
return {

View File

@ -32,6 +32,7 @@ import {
DocumentFlowFormContainerStep,
} from './document-flow-root';
import { FieldItem } from './field-item';
import { MissingSignatureFieldDialog } from './missing-signature-field-dialog';
import { type DocumentFlowStep, FRIENDLY_FIELD_TYPE } from './types';
const fontCaveat = Caveat({
@ -66,6 +67,8 @@ export const AddFieldsFormPartial = ({
canGoBack = false,
isDocumentPdfLoaded,
}: AddFieldsFormProps) => {
const [isMissingSignatureDialogVisible, setIsMissingSignatureDialogVisible] = useState(false);
const { isWithinPageBounds, getFieldPosition, getPage } = useDocumentElement();
const { currentStep, totalSteps, previousStep } = useStep();
const canRenderBackButtonAsRemove =
@ -317,6 +320,22 @@ export const AddFieldsFormPartial = ({
);
}, [recipientsByRole]);
const handleGoNextClick = () => {
const everySignerHasSignature = recipientsByRole.SIGNER.every((signer) =>
localFields.some(
(field) =>
(field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) &&
field.signerEmail === signer.email,
),
);
if (!everySignerHasSignature) {
setIsMissingSignatureDialogVisible(true);
} else {
void onFormSubmit();
}
};
return (
<>
<DocumentFlowFormContainerHeader
@ -602,9 +621,14 @@ export const AddFieldsFormPartial = ({
documentFlow.onBackStep?.();
}}
goBackLabel={canRenderBackButtonAsRemove ? 'Remove' : undefined}
onGoNextClick={() => void onFormSubmit()}
onGoNextClick={handleGoNextClick}
/>
</DocumentFlowFormContainerFooter>
<MissingSignatureFieldDialog
isOpen={isMissingSignatureDialogVisible}
onOpenChange={(value) => setIsMissingSignatureDialogVisible(value)}
/>
</>
);
};

View File

@ -0,0 +1,46 @@
'use client';
import { DialogClose } from '@radix-ui/react-dialog';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
export type MissingSignatureFieldDialogProps = {
isOpen: boolean;
onOpenChange: (value: boolean) => void;
};
export const MissingSignatureFieldDialog = ({
isOpen,
onOpenChange,
}: MissingSignatureFieldDialogProps) => {
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg" position="center">
<DialogHeader>
<DialogTitle>No signature field found</DialogTitle>
<DialogDescription>
<p className="mt-2">
Some signers have not been assigned a signature field. Please assign at least 1
signature field to each signer before proceeding.
</p>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary">
Close
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -44,6 +44,8 @@
--radius: 0.5rem;
--warning: 54 96% 45%;
--gold: 47.9 95.8% 53.1%;
}
.dark {
@ -83,6 +85,8 @@
--radius: 0.5rem;
--warning: 54 96% 45%;
--gold: 47.9 95.8% 53.1%;
}
}