Compare commits

..

15 Commits

Author SHA1 Message Date
Ephraim Atta-Duncan ce94cf0051 fix: build errors 2025-02-06 11:55:29 +00:00
Ephraim Atta-Duncan bccf4cd368 fix: merge conflicts 2025-02-06 11:47:44 +00:00
Ephraim Atta-Duncan c13988bb8f chore: remove conflicting translations 2025-02-06 11:41:29 +00:00
Ephraim Duncan 9f1831afcb Merge branch 'main' into experiment/self-sign 2024-11-21 17:50:07 +00:00
Ephraim Atta-Duncan 574a7449fa chore: merge with main 2024-11-18 07:30:44 +00:00
Ephraim Duncan 1aee1bb4cd Merge branch 'main' into experiment/self-sign 2024-11-15 20:33:34 +00:00
Ephraim Atta-Duncan 634dc2afd0 fix: self sign team documents 2024-11-15 20:25:09 +00:00
Ephraim Atta-Duncan 21d68f3275 fix: merge conflicts 2024-11-15 11:03:52 +00:00
Ephraim Atta-Duncan 63c98949bb chore: merge main 2024-10-25 14:20:29 +00:00
Ephraim Atta-Duncan 4348a949dd chore: changes based on review 2024-10-18 01:12:43 +00:00
Ephraim Atta-Duncan 2a098f89fa chore: add tests for self signing 2024-10-17 13:17:44 +00:00
Ephraim Atta-Duncan bb805ea93b fix: audit logs 2024-10-16 23:29:40 +00:00
Ephraim Atta-Duncan cc8b972fbc fix: recipient status stuck on uncompleted 2024-10-16 19:32:46 +00:00
Ephraim Atta-Duncan b55c419074 chore: minor changes 2024-10-16 19:03:14 +00:00
Ephraim Atta-Duncan f9e3993519 feat: avoid sending document if the owner is the only recipient 2024-10-16 17:10:01 +00:00
18 changed files with 485 additions and 335 deletions
@@ -14,4 +14,4 @@
"public-api": "Public API",
"embedding": "Embedding",
"webhooks": "Webhooks"
}
}
@@ -85,13 +85,12 @@ You can also set the recipient's role, which determines their actions and permis
Documenso has 4 roles for recipients with different permissions and actions.
| Role | Function | Action required | Signature |
| :-------: | :-----------------------------------------------------------------------------: | :-------------: | :-------: |
| Signer | Needs to sign signatures fields assigned to them. | Yes | Yes |
| Approver | Needs to approve the document as a whole. Signature optional. | Yes | Optional |
| Viewer | Needs to confirm they viewed the document. | Yes | No |
| Assistant | Can help prepare the document by filling in fields on behalf of other signers. | Yes | No |
| CC | Receives a copy of the signed document after completion. No action is required. | No | No |
| Role | Function | Action required | Signature |
| :------: | :-----------------------------------------------------------------------------: | :-------------: | :-------: |
| Signer | Needs to sign signatures fields assigned to them. | Yes | Yes |
| Approver | Needs to approve the document as a whole. Signature optional. | Yes | Optional |
| Viewer | Needs to confirm they viewed the document. | Yes | No |
| BCC | Receives a copy of the signed document after completion. No action is required. | No | No |
### Fields
@@ -16,13 +16,9 @@ import { Input } from '@documenso/ui/primitives/input';
export type SigningVolume = {
id: number;
name: string;
email: string;
signingVolume: number;
createdAt: Date;
planId: string;
userId?: number | null;
teamId?: number | null;
isTeam: boolean;
};
type LeaderboardTableProps = {
@@ -4,7 +4,7 @@ import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
import { LeaderboardTable, type SigningVolume } from './data-table-leaderboard';
import { LeaderboardTable } from './data-table-leaderboard';
import { search } from './fetch-leaderboard.actions';
type AdminLeaderboardProps = {
@@ -32,7 +32,7 @@ export default async function Leaderboard({ searchParams = {} }: AdminLeaderboar
const sortBy = searchParams.sortBy || 'signingVolume';
const sortOrder = searchParams.sortOrder || 'desc';
const { leaderboard, totalPages } = await search({
const { leaderboard: signingVolume, totalPages } = await search({
search: searchString,
page,
perPage,
@@ -40,22 +40,14 @@ export default async function Leaderboard({ searchParams = {} }: AdminLeaderboar
sortOrder,
});
const typedSigningVolume: SigningVolume[] = leaderboard.map((item) => ({
...item,
name: item.name || '',
createdAt: item.createdAt || new Date(),
}));
return (
<div>
<div className="flex items-center">
<h2 className="text-4xl font-semibold">
<Trans>Signing Volume</Trans>
</h2>
</div>
<h2 className="text-4xl font-semibold">
<Trans>Signing Volume</Trans>
</h2>
<div className="mt-8">
<LeaderboardTable
signingVolume={typedSigningVolume}
signingVolume={signingVolume}
totalPages={totalPages}
page={page}
perPage={perPage}
@@ -6,6 +6,7 @@ import { useRouter, useSearchParams } from 'next/navigation';
import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { useSession } from 'next-auth/react';
import { isValidLanguageCode } from '@documenso/lib/constants/i18n';
import {
@@ -55,6 +56,7 @@ export const EditDocumentForm = ({
const router = useRouter();
const searchParams = useSearchParams();
const team = useOptionalCurrentTeam();
const { data: session } = useSession();
const [isDocumentPdfLoaded, setIsDocumentPdfLoaded] = useState(false);
@@ -134,6 +136,18 @@ export const EditDocumentForm = ({
},
});
const { mutateAsync: selfSignDocument } = trpc.document.selfSignDocument.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newData) => {
utils.document.getDocumentWithDetailsById.setData(
{
documentId: initialDocument.id,
},
(oldData) => ({ ...(oldData || initialDocument), ...newData }),
);
},
});
const { mutateAsync: setPasswordForDocument } =
trpc.document.setPasswordForDocument.useMutation();
@@ -269,10 +283,22 @@ export const EditDocumentForm = ({
}
}
// Router refresh is here to clear the router cache for when navigating to /documents.
router.refresh();
const hasSameOwnerAsRecipient =
recipients.length === 1 && recipients[0].email === session?.user?.email;
setStep('subject');
if (hasSameOwnerAsRecipient) {
await selfSignDocument({
documentId: document.id,
teamId: team?.id,
});
router.push(`/sign/${recipients[0].token}`);
} else {
// Router refresh is here to clear the router cache for when navigating to /documents.
router.refresh();
setStep('subject');
}
} catch (err) {
console.error(err);
@@ -336,6 +336,16 @@ export const DocumentHistorySheet = ({
]}
/>
))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.SELF_SIGN }, ({ data }) => (
<DocumentHistorySheetChanges
values={[
{
key: 'Signed by',
value: data.recipientEmail,
},
]}
/>
))
.with(
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VISIBILITY_UPDATED },
({ data }) => (
@@ -2,7 +2,9 @@ import { expect, test } from '@playwright/test';
import { DateTime } from 'luxon';
import path from 'node:path';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getRecipientByEmail } from '@documenso/lib/server-only/recipient/get-recipient-by-email';
import { getRecipientById } from '@documenso/lib/server-only/recipient/get-recipient-by-id';
import { prisma } from '@documenso/prisma';
import {
DocumentSigningOrder,
@@ -612,7 +614,7 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip
expect(previousRecipient?.signingStatus).toBe(SigningStatus.SIGNED);
}
await page.goto(`/sign/${recipient?.token}`);
await page.goto(`/sign/${recipient!.token}`);
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
await page.locator(`#field-${recipientField.id}`).getByRole('button').click();
@@ -630,24 +632,22 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(`/sign/${recipient?.token}/complete`);
await page.waitForURL(`/sign/${recipient!.token}/complete`);
await expect(page.getByText('Document Signed')).toBeVisible();
const updatedRecipient = await prisma.recipient.findFirst({
where: { id: recipient?.id },
const updatedRecipient = await getRecipientById({
documentId: document.id,
id: recipient!.id,
});
expect(updatedRecipient?.signingStatus).toBe(SigningStatus.SIGNED);
}
// Wait for the document to be signed.
await page.waitForTimeout(5000);
const finalDocument = await prisma.document.findFirst({
where: { id: createdDocument?.id },
});
expect(finalDocument?.status).toBe(DocumentStatus.COMPLETED);
await expect(async () => {
const signedDocument = await getDocumentById({ id: document.id, userId: user.id });
expect(signedDocument?.status).toBe(DocumentStatus.COMPLETED);
}).toPass();
});
test('[DOCUMENT_FLOW]: should prevent out-of-order signing in sequential mode', async ({
@@ -655,7 +655,7 @@ test('[DOCUMENT_FLOW]: should prevent out-of-order signing in sequential mode',
}) => {
const user = await seedUser();
const { document, recipients } = await seedPendingDocumentWithFullFields({
const { recipients } = await seedPendingDocumentWithFullFields({
owner: user,
recipients: ['user1@example.com', 'user2@example.com', 'user3@example.com'],
fields: [FieldType.SIGNATURE],
@@ -682,3 +682,85 @@ test('[DOCUMENT_FLOW]: should prevent out-of-order signing in sequential mode',
await expect(page).not.toHaveURL(`/sign/${activeRecipient?.token}/waiting`);
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
});
test('[DOCUMENT_FLOW]: should be able to self sign a document', async ({ page }) => {
const user = await seedUser();
const document = await seedBlankDocument(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
});
const documentTitle = `Self-Signing-${Date.now()}.pdf`;
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
await page.getByLabel('Title').fill(documentTitle);
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
await page.getByRole('button', { name: 'Add myself' }).click();
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
},
});
await page.getByRole('button', { name: 'Sign', exact: true }).click();
const documentRecipients = await prisma.recipient.findMany({
where: {
documentId: document.id,
},
});
const { token, email, id: recipientId } = documentRecipients[0];
expect(documentRecipients.length).toBe(1);
expect(email).toBe(user.email);
await page.waitForURL(`/sign/${token}`);
await expect(page.getByRole('heading', { name: documentTitle })).toBeVisible();
const { status } = await getDocumentByToken(token);
expect(status).toBe(DocumentStatus.PENDING);
const fields = await prisma.field.findMany({
where: { recipientId, documentId: document.id },
});
const recipientField = fields[0];
expect(recipientField).not.toBeNull();
await page.locator(`#field-${recipientField.id}`).getByRole('button').click();
const canvas = page.locator('canvas#signature');
const box = await canvas.boundingBox();
if (box) {
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 4, box.y + box.height / 4);
await page.mouse.up();
}
await page.getByRole('button', { name: 'Sign', exact: true }).click();
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(`/sign/${token}/complete`);
await expect(page.getByText('Document Signed')).toBeVisible();
const updatedRecipient = await getRecipientById({ documentId: document.id, id: recipientId });
expect(updatedRecipient?.signingStatus).toBe(SigningStatus.SIGNED);
await expect(async () => {
const signedDocument = await getDocumentById({ id: document.id, userId: user.id });
expect(signedDocument?.status).toBe(DocumentStatus.COMPLETED);
}).toPass();
});
@@ -1,7 +1,15 @@
import { prisma } from '@documenso/prisma';
import { kyselyPrisma, sql } from '@documenso/prisma';
import { DocumentStatus, SubscriptionStatus } from '@documenso/prisma/client';
type GetSigningVolumeOptions = {
export type SigningVolume = {
id: number;
name: string;
signingVolume: number;
createdAt: Date;
planId: string;
};
export type GetSigningVolumeOptions = {
search?: string;
page?: number;
perPage?: number;
@@ -9,187 +17,85 @@ type GetSigningVolumeOptions = {
sortOrder?: 'asc' | 'desc';
};
export const getSigningVolume = async ({
export async function getSigningVolume({
search = '',
page = 1,
perPage = 10,
sortBy = 'signingVolume',
sortOrder = 'desc',
}: GetSigningVolumeOptions) => {
const validPage = Math.max(1, page);
const validPerPage = Math.max(1, perPage);
const skip = (validPage - 1) * validPerPage;
}: GetSigningVolumeOptions) {
const offset = Math.max(page - 1, 0) * perPage;
const activeSubscriptions = await prisma.subscription.findMany({
where: {
status: SubscriptionStatus.ACTIVE,
},
select: {
id: true,
planId: true,
userId: true,
teamId: true,
createdAt: true,
user: {
select: {
id: true,
name: true,
email: true,
createdAt: true,
},
},
team: {
select: {
id: true,
name: true,
teamEmail: {
select: {
email: true,
},
},
createdAt: true,
},
},
},
});
let findQuery = kyselyPrisma.$kysely
.selectFrom('Subscription as s')
.leftJoin('User as u', 's.userId', 'u.id')
.leftJoin('Team as t', 's.teamId', 't.id')
.leftJoin('Document as ud', (join) =>
join
.onRef('u.id', '=', 'ud.userId')
.on('ud.status', '=', sql.lit(DocumentStatus.COMPLETED))
.on('ud.deletedAt', 'is', null)
.on('ud.teamId', 'is', null),
)
.leftJoin('Document as td', (join) =>
join
.onRef('t.id', '=', 'td.teamId')
.on('td.status', '=', sql.lit(DocumentStatus.COMPLETED))
.on('td.deletedAt', 'is', null),
)
// @ts-expect-error - Raw SQL enum casting not properly typed by Kysely
.where(sql`s.status = ${SubscriptionStatus.ACTIVE}::"SubscriptionStatus"`)
.where((eb) =>
eb.or([
eb('u.name', 'ilike', `%${search}%`),
eb('u.email', 'ilike', `%${search}%`),
eb('t.name', 'ilike', `%${search}%`),
]),
)
.select([
's.id as id',
's.createdAt as createdAt',
's.planId as planId',
sql<string>`COALESCE(u.name, t.name, u.email, 'Unknown')`.as('name'),
sql<number>`COUNT(DISTINCT ud.id) + COUNT(DISTINCT td.id)`.as('signingVolume'),
])
.groupBy(['s.id', 'u.name', 't.name', 'u.email']);
const userSubscriptionsMap = new Map();
const teamSubscriptionsMap = new Map();
switch (sortBy) {
case 'name':
findQuery = findQuery.orderBy('name', sortOrder);
break;
case 'createdAt':
findQuery = findQuery.orderBy('createdAt', sortOrder);
break;
case 'signingVolume':
findQuery = findQuery.orderBy('signingVolume', sortOrder);
break;
default:
findQuery = findQuery.orderBy('signingVolume', 'desc');
}
activeSubscriptions.forEach((subscription) => {
const isTeam = !!subscription.teamId;
findQuery = findQuery.limit(perPage).offset(offset);
if (isTeam && subscription.teamId) {
if (!teamSubscriptionsMap.has(subscription.teamId)) {
teamSubscriptionsMap.set(subscription.teamId, {
id: subscription.id,
planId: subscription.planId,
teamId: subscription.teamId,
name: subscription.team?.name || '',
email: subscription.team?.teamEmail?.email || `Team ${subscription.team?.id}`,
createdAt: subscription.team?.createdAt,
isTeam: true,
subscriptionIds: [subscription.id],
});
} else {
const existingTeam = teamSubscriptionsMap.get(subscription.teamId);
existingTeam.subscriptionIds.push(subscription.id);
}
} else if (subscription.userId) {
if (!userSubscriptionsMap.has(subscription.userId)) {
userSubscriptionsMap.set(subscription.userId, {
id: subscription.id,
planId: subscription.planId,
userId: subscription.userId,
name: subscription.user?.name || '',
email: subscription.user?.email || '',
createdAt: subscription.user?.createdAt,
isTeam: false,
subscriptionIds: [subscription.id],
});
} else {
const existingUser = userSubscriptionsMap.get(subscription.userId);
existingUser.subscriptionIds.push(subscription.id);
}
}
});
const countQuery = kyselyPrisma.$kysely
.selectFrom('Subscription as s')
.leftJoin('User as u', 's.userId', 'u.id')
.leftJoin('Team as t', 's.teamId', 't.id')
// @ts-expect-error - Raw SQL enum casting not properly typed by Kysely
.where(sql`s.status = ${SubscriptionStatus.ACTIVE}::"SubscriptionStatus"`)
.where((eb) =>
eb.or([
eb('u.name', 'ilike', `%${search}%`),
eb('u.email', 'ilike', `%${search}%`),
eb('t.name', 'ilike', `%${search}%`),
]),
)
.select(({ fn }) => [fn.countAll().as('count')]);
const subscriptions = [
...Array.from(userSubscriptionsMap.values()),
...Array.from(teamSubscriptionsMap.values()),
];
const filteredSubscriptions = search
? subscriptions.filter((sub) => {
const searchLower = search.toLowerCase();
return (
sub.name?.toLowerCase().includes(searchLower) ||
sub.email?.toLowerCase().includes(searchLower)
);
})
: subscriptions;
const signingVolume = await Promise.all(
filteredSubscriptions.map(async (subscription) => {
let signingVolume = 0;
if (subscription.userId && !subscription.isTeam) {
const personalCount = await prisma.document.count({
where: {
userId: subscription.userId,
status: DocumentStatus.COMPLETED,
teamId: null,
},
});
signingVolume += personalCount;
const userTeams = await prisma.teamMember.findMany({
where: {
userId: subscription.userId,
},
select: {
teamId: true,
},
});
if (userTeams.length > 0) {
const teamIds = userTeams.map((team) => team.teamId);
const teamCount = await prisma.document.count({
where: {
teamId: {
in: teamIds,
},
status: DocumentStatus.COMPLETED,
},
});
signingVolume += teamCount;
}
}
if (subscription.teamId) {
const teamCount = await prisma.document.count({
where: {
teamId: subscription.teamId,
status: DocumentStatus.COMPLETED,
},
});
signingVolume += teamCount;
}
return {
...subscription,
signingVolume,
};
}),
);
const sortedResults = [...signingVolume].sort((a, b) => {
if (sortBy === 'name') {
return sortOrder === 'asc'
? (a.name || '').localeCompare(b.name || '')
: (b.name || '').localeCompare(a.name || '');
}
if (sortBy === 'createdAt') {
const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0;
const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0;
return sortOrder === 'asc' ? dateA - dateB : dateB - dateA;
}
return sortOrder === 'asc'
? a.signingVolume - b.signingVolume
: b.signingVolume - a.signingVolume;
});
const paginatedResults = sortedResults.slice(skip, skip + validPerPage);
const totalPages = Math.ceil(sortedResults.length / validPerPage);
const [results, [{ count }]] = await Promise.all([findQuery.execute(), countQuery.execute()]);
return {
leaderboard: paginatedResults,
totalPages,
leaderboard: results,
totalPages: Math.ceil(Number(count) / perPage),
};
};
}
@@ -0,0 +1,177 @@
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, RecipientRole, SendStatus, SigningStatus } from '@documenso/prisma/client';
import { jobs } from '../../jobs/client';
import { getFile } from '../../universal/upload/get-file';
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
export type SelfSignDocumentOptions = {
documentId: number;
userId: number;
teamId?: number;
requestMetadata?: ApiRequestMetadata;
};
export const selfSignDocument = async ({
documentId,
userId,
teamId,
requestMetadata,
}: SelfSignDocumentOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
select: {
id: true,
name: true,
email: true,
},
});
const document = await prisma.document.findUnique({
where: {
id: documentId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
include: {
recipients: {
orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }],
},
documentMeta: true,
documentData: true,
},
});
if (!document) {
throw new Error('Document not found');
}
if (document.recipients.length === 0) {
throw new Error('Document has no recipients');
}
if (document.recipients.length !== 1 || document.recipients[0].email !== user.email) {
throw new Error('Invalid document for self-signing');
}
if (document.status === DocumentStatus.COMPLETED) {
throw new Error('Can not sign completed document');
}
const { documentData } = document;
if (!documentData || !documentData.data) {
throw new Error('Document data not found');
}
if (document.formValues) {
const file = await getFile(documentData);
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>,
});
const newDocumentData = await putPdfFile({
name: document.title,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(prefilled),
});
const result = await prisma.document.update({
where: {
id: document.id,
},
data: {
documentDataId: newDocumentData.id,
},
});
Object.assign(document, result);
}
const recipientHasNoActionToTake =
document.recipients[0].role === RecipientRole.CC ||
document.recipients[0].signingStatus === SigningStatus.SIGNED;
if (recipientHasNoActionToTake) {
await jobs.triggerJob({
name: 'internal.seal-document',
payload: {
documentId,
requestMetadata: requestMetadata?.requestMetadata,
},
});
return await prisma.document.findFirstOrThrow({
where: {
id: documentId,
},
include: {
recipients: true,
},
});
}
const updatedDocument = await prisma.$transaction(async (tx) => {
if (document.status === DocumentStatus.DRAFT) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.SELF_SIGN,
documentId: document.id,
requestMetadata: requestMetadata?.requestMetadata,
user,
data: {
recipientId: document.recipients[0].id,
recipientEmail: document.recipients[0].email,
recipientName: document.recipients[0].name,
recipientRole: document.recipients[0].role,
},
}),
});
}
await tx.recipient.update({
where: {
id: document.recipients[0].id,
},
data: {
sendStatus: SendStatus.SENT,
},
});
return await tx.document.update({
where: {
id: documentId,
},
data: {
status: DocumentStatus.PENDING,
},
include: {
recipients: true,
},
});
});
return updatedDocument;
};
@@ -94,7 +94,7 @@ export const sendDocument = async ({
const { documentData } = document;
if (!documentData.data) {
if (!documentData || !documentData.data) {
throw new Error('Document data not found');
}
+10
View File
@@ -13,6 +13,7 @@ import { ZRecipientAccessAuthTypesSchema, ZRecipientActionAuthTypesSchema } from
export const ZDocumentAuditLogTypeSchema = z.enum([
// Document actions.
'EMAIL_SENT',
'SELF_SIGN',
// Document modification events.
'FIELD_CREATED',
@@ -181,6 +182,14 @@ export const ZDocumentAuditLogEventEmailSentSchema = z.object({
}),
});
/**
* Event: Self sign
*/
export const ZDocumentAuditLogSelfSignSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.SELF_SIGN),
data: ZBaseRecipientDataSchema,
});
/**
* Event: Document completed.
*/
@@ -566,6 +575,7 @@ export const ZDocumentAuditLogBaseSchema = z.object({
export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
z.union([
ZDocumentAuditLogEventEmailSentSchema,
ZDocumentAuditLogSelfSignSchema,
ZDocumentAuditLogEventDocumentCompletedSchema,
ZDocumentAuditLogEventDocumentCreatedSchema,
ZDocumentAuditLogEventDocumentDeletedSchema,
+8 -10
View File
@@ -369,16 +369,6 @@ export const formatDocumentAuditLogAction = (
identified: result,
};
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED }, ({ data }) => {
const userName = prefix || _(msg`Recipient`);
const result = msg`${userName} rejected the document`;
return {
anonymous: result,
identified: result,
};
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT }, ({ data }) => ({
anonymous: data.isResending ? msg`Email resent` : msg`Email sent`,
identified: data.isResending
@@ -389,6 +379,14 @@ export const formatDocumentAuditLogAction = (
anonymous: msg`Document completed`,
identified: msg`Document completed`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.SELF_SIGN }, () => ({
anonymous: msg`Self-signed document`,
identified: msg`${prefix} self-signed the document`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED }, ({ data }) => ({
anonymous: msg`Document rejected`,
identified: msg`${prefix} rejected the document: ${data.reason}`,
}))
.exhaustive();
return {
@@ -1,18 +0,0 @@
/*
Warnings:
- You are about to drop the column `expires` on the `Session` table. All the data in the column will be lost.
- Added the required column `expiresAt` to the `Session` table without a default value. This is not possible if the table is not empty.
- Added the required column `updatedAt` to the `Session` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "Account" ADD COLUMN "password" TEXT;
-- AlterTable
ALTER TABLE "Session" DROP COLUMN "expires",
ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "expiresAt" TIMESTAMP(3) NOT NULL,
ADD COLUMN "ipAddress" TEXT,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL,
ADD COLUMN "userAgent" TEXT;
+5 -12
View File
@@ -270,25 +270,18 @@ model Account {
scope String?
id_token String? @db.Text
session_state String?
password String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
id String @id @default(cuid())
sessionToken String @unique
userId Int
ipAddress String?
userAgent String?
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
expires DateTime
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
}
enum DocumentStatus {
+23 -80
View File
@@ -5,18 +5,6 @@ import { hashSync } from '@documenso/lib/server-only/auth/hash';
import { prisma } from '..';
import { DocumentDataType, DocumentSource, Role, TeamMemberRole } from '../client';
import { seedPendingDocument } from './documents';
import { seedDirectTemplate, seedTemplate } from './templates';
const createDocumentData = async ({ documentData }: { documentData: string }) => {
return prisma.documentData.create({
data: {
type: DocumentDataType.BYTES_64,
data: documentData,
initialData: documentData,
},
});
};
export const seedDatabase = async () => {
const examplePdf = fs
@@ -51,80 +39,35 @@ export const seedDatabase = async () => {
update: {},
});
for (let i = 1; i <= 4; i++) {
const documentData = await createDocumentData({ documentData: examplePdf });
await prisma.document.create({
data: {
source: DocumentSource.DOCUMENT,
title: `Example Document ${i}`,
documentDataId: documentData.id,
userId: exampleUser.id,
recipients: {
create: {
name: String(adminUser.name),
email: adminUser.email,
token: Math.random().toString(36).slice(2, 9),
},
},
},
});
}
for (let i = 1; i <= 4; i++) {
const documentData = await createDocumentData({ documentData: examplePdf });
await prisma.document.create({
data: {
source: DocumentSource.DOCUMENT,
title: `Document ${i}`,
documentDataId: documentData.id,
userId: adminUser.id,
recipients: {
create: {
name: String(exampleUser.name),
email: exampleUser.email,
token: Math.random().toString(36).slice(2, 9),
},
},
},
});
}
await seedPendingDocument(exampleUser, [adminUser], {
key: 'example-pending',
createDocumentOptions: {
title: 'Pending Document',
const examplePdfData = await prisma.documentData.upsert({
where: {
id: 'clmn0kv5k0000pe04vcqg5zla',
},
create: {
id: 'clmn0kv5k0000pe04vcqg5zla',
type: DocumentDataType.BYTES_64,
data: examplePdf,
initialData: examplePdf,
},
update: {},
});
await seedPendingDocument(adminUser, [exampleUser], {
key: 'admin-pending',
createDocumentOptions: {
title: 'Pending Document',
await prisma.document.create({
data: {
source: DocumentSource.DOCUMENT,
title: 'Example Document',
documentDataId: examplePdfData.id,
userId: exampleUser.id,
recipients: {
create: {
name: String(adminUser.name),
email: adminUser.email,
token: Math.random().toString(36).slice(2, 9),
},
},
},
});
await Promise.all([
seedTemplate({
title: 'Template 1',
userId: exampleUser.id,
}),
seedDirectTemplate({
title: 'Direct Template 1',
userId: exampleUser.id,
}),
seedTemplate({
title: 'Template 1',
userId: adminUser.id,
}),
seedDirectTemplate({
title: 'Direct Template 1',
userId: adminUser.id,
}),
]);
const testUsers = [
'test@documenso.com',
'test2@documenso.com',
@@ -20,6 +20,7 @@ import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/
import { moveDocumentToTeam } from '@documenso/lib/server-only/document/move-document-to-team';
import { resendDocument } from '@documenso/lib/server-only/document/resend-document';
import { searchDocumentsWithKeyword } from '@documenso/lib/server-only/document/search-documents-with-keyword';
import { selfSignDocument } from '@documenso/lib/server-only/document/self-sign-document';
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
import { updateDocument } from '@documenso/lib/server-only/document/update-document';
import { symmetricEncrypt } from '@documenso/lib/universal/crypto';
@@ -50,6 +51,7 @@ import {
ZMoveDocumentToTeamSchema,
ZResendDocumentMutationSchema,
ZSearchDocumentsMutationSchema,
ZSelfSignDocumentMutationSchema,
ZSetPasswordForDocumentMutationSchema,
ZSetSigningOrderForDocumentMutationSchema,
ZSuccessResponseSchema,
@@ -467,6 +469,28 @@ export const documentRouter = router({
});
}),
selfSignDocument: authenticatedProcedure
.input(ZSelfSignDocumentMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
const { documentId, teamId } = input;
return await selfSignDocument({
userId: ctx.user.id,
documentId,
teamId,
requestMetadata: ctx.metadata,
});
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to self sign this document. Please try again later.',
});
}
}),
/**
* @public
*
@@ -293,6 +293,11 @@ export const ZDistributeDocumentRequestSchema = z.object({
.optional(),
});
export const ZSelfSignDocumentMutationSchema = z.object({
documentId: z.number(),
teamId: z.number().optional(),
});
export const ZDistributeDocumentResponseSchema = ZDocumentLiteSchema;
export const ZSetPasswordForDocumentMutationSchema = z.object({
@@ -21,6 +21,7 @@ import {
Type,
User,
} from 'lucide-react';
import { useSession } from 'next-auth/react';
import { useFieldArray, useForm } from 'react-hook-form';
import { useHotkeys } from 'react-hotkeys-hook';
import { prop, sortBy } from 'remeda';
@@ -120,6 +121,7 @@ export const AddFieldsFormPartial = ({
teamId,
}: AddFieldsFormProps) => {
const { toast } = useToast();
const { data: session } = useSession();
const { _ } = useLingui();
const [isMissingSignatureDialogVisible, setIsMissingSignatureDialogVisible] = useState(false);
@@ -558,6 +560,10 @@ export const AddFieldsFormPartial = ({
);
}, [recipientsByRole]);
const hasSameOwnerAsRecipient =
recipientsByRole.SIGNER.length === 1 &&
recipientsByRole.SIGNER[0].email === session?.user?.email;
const handleAdvancedSettings = () => {
setShowAdvancedSettings((prev) => !prev);
};
@@ -1132,6 +1138,7 @@ export const AddFieldsFormPartial = ({
documentFlow.onBackStep?.();
}}
goBackLabel={canRenderBackButtonAsRemove ? msg`Remove` : undefined}
goNextLabel={hasSameOwnerAsRecipient ? msg`Sign` : undefined}
onGoNextClick={handleGoNextClick}
/>
</DocumentFlowFormContainerFooter>