feat: add document version history UI

This commit is contained in:
David Nguyen
2024-02-15 18:20:10 +11:00
parent 524a2f4ea9
commit bd3c64658a
20 changed files with 1144 additions and 193 deletions

View File

@ -0,0 +1,19 @@
import { DOCUMENT_EMAIL_TYPE } from '../types/document-audit-logs';
export const DOCUMENT_AUDIT_LOG_EMAIL_FORMAT = {
[DOCUMENT_EMAIL_TYPE.SIGNING_REQUEST]: {
description: 'Signing request',
},
[DOCUMENT_EMAIL_TYPE.VIEW_REQUEST]: {
description: 'Viewing request',
},
[DOCUMENT_EMAIL_TYPE.APPROVE_REQUEST]: {
description: 'Approval request',
},
[DOCUMENT_EMAIL_TYPE.CC]: {
description: 'CC',
},
[DOCUMENT_EMAIL_TYPE.DOCUMENT_COMPLETED]: {
description: 'Document completed',
},
} satisfies Record<keyof typeof DOCUMENT_EMAIL_TYPE, unknown>;

View File

@ -1,29 +1,31 @@
import { RecipientRole } from '@documenso/prisma/client';
export const RECIPIENT_ROLES_DESCRIPTION: {
[key in RecipientRole]: { actionVerb: string; progressiveVerb: string; roleName: string };
} = {
export const RECIPIENT_ROLES_DESCRIPTION = {
[RecipientRole.APPROVER]: {
actionVerb: 'Approve',
actioned: 'Approved',
progressiveVerb: 'Approving',
roleName: 'Approver',
},
[RecipientRole.CC]: {
actionVerb: 'CC',
actioned: 'CCed',
progressiveVerb: 'CC',
roleName: 'Cc',
},
[RecipientRole.SIGNER]: {
actionVerb: 'Sign',
actioned: 'Signed',
progressiveVerb: 'Signing',
roleName: 'Signer',
},
[RecipientRole.VIEWER]: {
actionVerb: 'View',
actioned: 'Viewed',
progressiveVerb: 'Viewing',
roleName: 'Viewer',
},
};
} satisfies Record<keyof typeof RecipientRole, unknown>;
export const RECIPIENT_ROLE_TO_EMAIL_TYPE = {
[RecipientRole.SIGNER]: 'SIGNING_REQUEST',

View File

@ -89,17 +89,21 @@ export const upsertDocumentMeta = async ({
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED,
documentId,
user,
requestMetadata,
data: {
changes: diffDocumentMetaChanges(originalDocumentMeta ?? {}, upsertedDocumentMeta),
},
}),
});
const changes = diffDocumentMetaChanges(originalDocumentMeta ?? {}, upsertedDocumentMeta);
if (changes.length > 0) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED,
documentId,
user,
requestMetadata,
data: {
changes: diffDocumentMetaChanges(originalDocumentMeta ?? {}, upsertedDocumentMeta),
},
}),
});
}
return upsertedDocumentMeta;
});

View File

@ -9,27 +9,72 @@ import { prisma } from '@documenso/prisma';
import { DocumentStatus } from '@documenso/prisma/client';
import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
export type DeleteDocumentOptions = {
id: number;
userId: number;
status: DocumentStatus;
requestMetadata?: RequestMetadata;
};
export const deleteDocument = async ({ id, userId, status }: DeleteDocumentOptions) => {
export const deleteDocument = async ({
id,
userId,
status,
requestMetadata,
}: DeleteDocumentOptions) => {
await prisma.document.findFirstOrThrow({
where: {
id,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
},
},
},
},
],
},
});
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
});
// if the document is a draft, hard-delete
if (status === DocumentStatus.DRAFT) {
return await prisma.document.delete({ where: { id, userId, status: DocumentStatus.DRAFT } });
return await prisma.$transaction(async (tx) => {
// Currently redundant since deleting a document will delete the audit logs.
// However may be useful if we disassociate audit lgos and documents if required.
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
documentId: id,
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
user,
requestMetadata,
data: {
type: 'HARD',
},
}),
});
return await tx.document.delete({ where: { id, status: DocumentStatus.DRAFT } });
});
}
// if the document is pending, send cancellation emails to all recipients
if (status === DocumentStatus.PENDING) {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
});
const document = await prisma.document.findUnique({
where: {
id,
@ -77,12 +122,26 @@ export const deleteDocument = async ({ id, userId, status }: DeleteDocumentOptio
}
// If the document is not a draft, only soft-delete.
return await prisma.document.update({
where: {
id,
},
data: {
deletedAt: new Date().toISOString(),
},
return await prisma.$transaction(async (tx) => {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
documentId: id,
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
user,
requestMetadata,
data: {
type: 'SOFT',
},
}),
});
return await tx.document.update({
where: {
id,
},
data: {
deletedAt: new Date().toISOString(),
},
});
});
};

View File

@ -0,0 +1,115 @@
import type { FindResultSet } from '@documenso/lib/types/find-result-set';
import { prisma } from '@documenso/prisma';
import type { DocumentAuditLog } from '@documenso/prisma/client';
import type { Prisma } from '@documenso/prisma/client';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { parseDocumentAuditLogData } from '../../utils/document-audit-logs';
export interface FindDocumentAuditLogsOptions {
userId: number;
documentId: number;
page?: number;
perPage?: number;
orderBy?: {
column: keyof DocumentAuditLog;
direction: 'asc' | 'desc';
};
cursor?: string;
filterForRecentActivity?: boolean;
}
export const findDocumentAuditLogs = async ({
userId,
documentId,
page = 1,
perPage = 30,
orderBy,
cursor,
filterForRecentActivity,
}: FindDocumentAuditLogsOptions) => {
const orderByColumn = orderBy?.column ?? 'createdAt';
const orderByDirection = orderBy?.direction ?? 'desc';
await prisma.document.findFirstOrThrow({
where: {
id: documentId,
OR: [
{
userId,
},
{
team: {
members: {
some: {
userId,
},
},
},
},
],
},
});
const whereClause: Prisma.DocumentAuditLogWhereInput = {
documentId,
};
// Filter events down to what we consider recent activity.
if (filterForRecentActivity) {
whereClause.OR = [
{
type: {
in: [
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT,
],
},
},
{
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
data: {
path: ['isResending'],
equals: true,
},
},
];
}
const [data, count] = await Promise.all([
prisma.documentAuditLog.findMany({
where: whereClause,
skip: Math.max(page - 1, 0) * perPage,
take: perPage + 1,
orderBy: {
[orderByColumn]: orderByDirection,
},
cursor: cursor ? { id: cursor } : undefined,
}),
prisma.documentAuditLog.count({
where: whereClause,
}),
]);
let nextCursor: string | undefined = undefined;
const parsedData = data.map((auditLog) => parseDocumentAuditLogData(auditLog));
if (parsedData.length > perPage) {
const nextItem = parsedData.pop();
nextCursor = nextItem!.id;
}
return {
data: parsedData,
count,
currentPage: Math.max(page, 1),
perPage,
totalPages: Math.ceil(count / perPage),
nextCursor,
} satisfies FindResultSet<typeof parsedData> & { nextCursor?: string };
};

View File

@ -152,13 +152,27 @@ export const sendDocument = async ({
}),
);
const updatedDocument = await prisma.document.update({
where: {
id: documentId,
},
data: {
status: DocumentStatus.PENDING,
},
const updatedDocument = await prisma.$transaction(async (tx) => {
if (document.status === DocumentStatus.DRAFT) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT,
documentId: document.id,
requestMetadata,
user,
data: {},
}),
});
}
return await tx.document.update({
where: {
id: documentId,
},
data: {
status: DocumentStatus.PENDING,
},
});
});
return updatedDocument;

View File

@ -21,15 +21,24 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
'RECIPIENT_UPDATED',
// Document events.
'DOCUMENT_COMPLETED', // When the document is sealed and fully completed.
'DOCUMENT_CREATED', // When the document is created.
'DOCUMENT_DELETED', // When the document is soft deleted.
'DOCUMENT_FIELD_INSERTED', // When a field is inserted (signed/approved/etc) by a recipient.
'DOCUMENT_FIELD_UNINSERTED', // When a field is uninserted by a recipient.
'DOCUMENT_META_UPDATED', // When the document meta data is updated.
'DOCUMENT_OPENED', // When the document is opened by a recipient.
'DOCUMENT_RECIPIENT_COMPLETED', // When a recipient completes all their required tasks for the document.
'DOCUMENT_SENT', // When the document transitions from DRAFT to PENDING.
'DOCUMENT_TITLE_UPDATED', // When the document title is updated.
]);
export const ZDocumentAuditLogEmailTypeSchema = z.enum([
'SIGNING_REQUEST',
'VIEW_REQUEST',
'APPROVE_REQUEST',
'CC',
'DOCUMENT_COMPLETED',
'DOCUMENT_CREATED',
'DOCUMENT_DELETED',
'DOCUMENT_FIELD_INSERTED',
'DOCUMENT_FIELD_UNINSERTED',
'DOCUMENT_META_UPDATED',
'DOCUMENT_OPENED',
'DOCUMENT_TITLE_UPDATED',
'DOCUMENT_RECIPIENT_COMPLETED',
]);
export const ZDocumentMetaDiffTypeSchema = z.enum([
@ -40,10 +49,12 @@ export const ZDocumentMetaDiffTypeSchema = z.enum([
'SUBJECT',
'TIMEZONE',
]);
export const ZFieldDiffTypeSchema = z.enum(['DIMENSION', 'POSITION']);
export const ZRecipientDiffTypeSchema = z.enum(['NAME', 'ROLE', 'EMAIL']);
export const DOCUMENT_AUDIT_LOG_TYPE = ZDocumentAuditLogTypeSchema.Enum;
export const DOCUMENT_EMAIL_TYPE = ZDocumentAuditLogEmailTypeSchema.Enum;
export const DOCUMENT_META_DIFF_TYPE = ZDocumentMetaDiffTypeSchema.Enum;
export const FIELD_DIFF_TYPE = ZFieldDiffTypeSchema.Enum;
export const RECIPIENT_DIFF_TYPE = ZRecipientDiffTypeSchema.Enum;
@ -140,13 +151,7 @@ const ZBaseRecipientDataSchema = z.object({
export const ZDocumentAuditLogEventEmailSentSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT),
data: ZBaseRecipientDataSchema.extend({
emailType: z.enum([
'SIGNING_REQUEST',
'VIEW_REQUEST',
'APPROVE_REQUEST',
'CC',
'DOCUMENT_COMPLETED',
]),
emailType: ZDocumentAuditLogEmailTypeSchema,
isResending: z.boolean(),
}),
});
@ -171,6 +176,16 @@ export const ZDocumentAuditLogEventDocumentCreatedSchema = z.object({
}),
});
/**
* Event: Document deleted.
*/
export const ZDocumentAuditLogEventDocumentDeletedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED),
data: z.object({
type: z.enum(['SOFT', 'HARD']),
}),
});
/**
* Event: Document field inserted.
*/
@ -247,6 +262,14 @@ export const ZDocumentAuditLogEventDocumentRecipientCompleteSchema = z.object({
data: ZBaseRecipientDataSchema,
});
/**
* Event: Document sent.
*/
export const ZDocumentAuditLogEventDocumentSentSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT),
data: z.object({}),
});
/**
* Event: Document title updated.
*/
@ -314,6 +337,11 @@ export const ZDocumentAuditLogBaseSchema = z.object({
id: z.string(),
createdAt: z.date(),
documentId: z.number(),
name: z.string().optional().nullable(),
email: z.string().optional().nullable(),
userId: z.number().optional().nullable(),
userAgent: z.string().optional().nullable(),
ipAddress: z.string().optional().nullable(),
});
export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
@ -321,11 +349,13 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
ZDocumentAuditLogEventEmailSentSchema,
ZDocumentAuditLogEventDocumentCompletedSchema,
ZDocumentAuditLogEventDocumentCreatedSchema,
ZDocumentAuditLogEventDocumentDeletedSchema,
ZDocumentAuditLogEventDocumentFieldInsertedSchema,
ZDocumentAuditLogEventDocumentFieldUninsertedSchema,
ZDocumentAuditLogEventDocumentMetaUpdatedSchema,
ZDocumentAuditLogEventDocumentOpenedSchema,
ZDocumentAuditLogEventDocumentRecipientCompleteSchema,
ZDocumentAuditLogEventDocumentSentSchema,
ZDocumentAuditLogEventDocumentTitleUpdatedSchema,
ZDocumentAuditLogEventFieldCreatedSchema,
ZDocumentAuditLogEventFieldRemovedSchema,
@ -348,3 +378,8 @@ export type TDocumentAuditLogDocumentMetaDiffSchema = z.infer<
export type TDocumentAuditLogRecipientDiffSchema = z.infer<
typeof ZDocumentAuditLogRecipientDiffSchema
>;
export type DocumentAuditLogByType<T = TDocumentAuditLog['type']> = Extract<
TDocumentAuditLog,
{ type: T }
>;

View File

@ -1,5 +1,14 @@
import type { DocumentAuditLog, DocumentMeta, Field, Recipient } from '@documenso/prisma/client';
import { match } from 'ts-pattern';
import type {
DocumentAuditLog,
DocumentMeta,
Field,
Recipient,
RecipientRole,
} from '@documenso/prisma/client';
import { RECIPIENT_ROLES_DESCRIPTION } from '../constants/recipient-roles';
import type {
TDocumentAuditLog,
TDocumentAuditLogDocumentMetaDiffSchema,
@ -7,6 +16,7 @@ import type {
TDocumentAuditLogRecipientDiffSchema,
} from '../types/document-audit-logs';
import {
DOCUMENT_AUDIT_LOG_TYPE,
DOCUMENT_META_DIFF_TYPE,
FIELD_DIFF_TYPE,
RECIPIENT_DIFF_TYPE,
@ -58,6 +68,7 @@ export const parseDocumentAuditLogData = (auditLog: DocumentAuditLog): TDocument
// Handle any required migrations here.
if (!data.success) {
console.error(data.error);
throw new Error('Migration required');
}
@ -203,3 +214,114 @@ export const diffDocumentMetaChanges = (
return diffs;
};
/**
* Formats the audit log into a description of the action.
*
* Provide a userId to prefix the action with the user, example 'X did Y'.
*/
export const formatDocumentAuditLogActionString = (
auditLog: TDocumentAuditLog,
userId?: number,
) => {
const { prefix, description } = formatDocumentAuditLogAction(auditLog, userId);
return prefix ? `${prefix} ${description}` : description;
};
/**
* Formats the audit log into a description of the action.
*
* Provide a userId to prefix the action with the user, example 'X did Y'.
*/
export const formatDocumentAuditLogAction = (auditLog: TDocumentAuditLog, userId?: number) => {
let prefix = userId === auditLog.userId ? 'You' : auditLog.name || auditLog.email || '';
const description = match(auditLog)
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED }, () => ({
anonymous: 'A field was added',
identified: 'added a field',
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_DELETED }, () => ({
anonymous: 'A field was removed',
identified: 'removed a field',
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED }, () => ({
anonymous: 'A field was updated',
identified: 'updated a field',
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED }, () => ({
anonymous: 'A recipient was added',
identified: 'added a recipient',
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED }, () => ({
anonymous: 'A recipient was removed',
identified: 'removed a recipient',
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED }, () => ({
anonymous: 'A recipient was updated',
identified: 'updated a recipient',
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED }, () => ({
anonymous: 'Document created',
identified: 'created the document',
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED }, () => ({
anonymous: 'Document deleted',
identified: 'deleted the document',
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED }, () => ({
anonymous: 'Field signed',
identified: 'signed a field',
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED }, () => ({
anonymous: 'Field unsigned',
identified: 'unsigned a field',
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED }, () => ({
anonymous: 'Document updated',
identified: 'updated the document',
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED }, () => ({
anonymous: 'Document opened',
identified: 'opened the document',
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_TITLE_UPDATED }, () => ({
anonymous: 'Document title updated',
identified: 'updated the document title',
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT }, () => ({
anonymous: 'Document sent',
identified: 'sent the document',
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED }, ({ data }) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const action = RECIPIENT_ROLES_DESCRIPTION[data.recipientRole as RecipientRole]?.actioned;
const value = action ? `${action.toLowerCase()} the document` : 'completed their task';
return {
anonymous: `Recipient ${value}`,
identified: value,
};
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT }, ({ data }) => ({
anonymous: `Email ${data.isResending ? 'resent' : 'sent'}`,
identified: `${data.isResending ? 'resent' : 'sent'} an email`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED }, () => {
// Clear the prefix since this should be considered an 'anonymous' event.
prefix = '';
return {
anonymous: 'Document completed',
identified: 'Document completed',
};
})
.exhaustive();
return {
prefix,
description: prefix ? description.identified : description.anonymous,
};
};