mirror of
https://github.com/documenso/documenso.git
synced 2025-11-19 03:01:59 +10:00
Merge branch 'main' into feat/allow-same-signer-email-multiple-times
This commit is contained in:
@ -2,7 +2,7 @@ import { useMemo } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { match } from 'ts-pattern';
|
||||
import { P, match } from 'ts-pattern';
|
||||
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import { RecipientRole } from '@documenso/prisma/client';
|
||||
@ -40,11 +40,9 @@ export const TemplateDocumentInvite = ({
|
||||
|
||||
const rejectDocumentLink = useMemo(() => {
|
||||
const url = new URL(signDocumentLink);
|
||||
|
||||
url.searchParams.set('reject', 'true');
|
||||
|
||||
return url.toString();
|
||||
}, []);
|
||||
}, [signDocumentLink]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -52,31 +50,32 @@ export const TemplateDocumentInvite = ({
|
||||
|
||||
<Section>
|
||||
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
|
||||
{selfSigner ? (
|
||||
<Trans>
|
||||
Please {_(actionVerb).toLowerCase()} your document
|
||||
<br />"{documentName}"
|
||||
</Trans>
|
||||
) : isTeamInvite ? (
|
||||
<>
|
||||
{includeSenderDetails ? (
|
||||
<Trans>
|
||||
{inviterName} on behalf of "{teamName}" has invited you to{' '}
|
||||
{_(actionVerb).toLowerCase()}
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
{teamName} has invited you to {_(actionVerb).toLowerCase()}
|
||||
</Trans>
|
||||
)}
|
||||
<br />"{documentName}"
|
||||
</>
|
||||
) : (
|
||||
<Trans>
|
||||
{inviterName} has invited you to {_(actionVerb).toLowerCase()}
|
||||
<br />"{documentName}"
|
||||
</Trans>
|
||||
)}
|
||||
{match({ selfSigner, isTeamInvite, includeSenderDetails, teamName })
|
||||
.with({ selfSigner: true }, () => (
|
||||
<Trans>
|
||||
Please {_(actionVerb).toLowerCase()} your document
|
||||
<br />"{documentName}"
|
||||
</Trans>
|
||||
))
|
||||
.with({ isTeamInvite: true, includeSenderDetails: true, teamName: P.string }, () => (
|
||||
<Trans>
|
||||
{inviterName} on behalf of "{teamName}" has invited you to{' '}
|
||||
{_(actionVerb).toLowerCase()}
|
||||
<br />"{documentName}"
|
||||
</Trans>
|
||||
))
|
||||
.with({ isTeamInvite: true, teamName: P.string }, () => (
|
||||
<Trans>
|
||||
{teamName} has invited you to {_(actionVerb).toLowerCase()}
|
||||
<br />"{documentName}"
|
||||
</Trans>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<Trans>
|
||||
{inviterName} has invited you to {_(actionVerb).toLowerCase()}
|
||||
<br />"{documentName}"
|
||||
</Trans>
|
||||
))}
|
||||
</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base text-slate-400">
|
||||
|
||||
91
packages/email/templates/bulk-send-complete.tsx
Normal file
91
packages/email/templates/bulk-send-complete.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { Body, Container, Head, Html, Preview, Section, Text } from '../components';
|
||||
import { TemplateFooter } from '../template-components/template-footer';
|
||||
|
||||
export interface BulkSendCompleteEmailProps {
|
||||
userName: string;
|
||||
templateName: string;
|
||||
totalProcessed: number;
|
||||
successCount: number;
|
||||
failedCount: number;
|
||||
errors: string[];
|
||||
assetBaseUrl?: string;
|
||||
}
|
||||
|
||||
export const BulkSendCompleteEmail = ({
|
||||
userName,
|
||||
templateName,
|
||||
totalProcessed,
|
||||
successCount,
|
||||
failedCount,
|
||||
errors,
|
||||
}: BulkSendCompleteEmailProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(msg`Bulk send operation complete for template "${templateName}"`)}</Preview>
|
||||
<Body className="mx-auto my-auto bg-white font-sans">
|
||||
<Section>
|
||||
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
<Text className="text-sm">
|
||||
<Trans>Hi {userName},</Trans>
|
||||
</Text>
|
||||
|
||||
<Text className="text-sm">
|
||||
<Trans>Your bulk send operation for template "{templateName}" has completed.</Trans>
|
||||
</Text>
|
||||
|
||||
<Text className="text-lg font-semibold">
|
||||
<Trans>Summary:</Trans>
|
||||
</Text>
|
||||
|
||||
<ul className="my-2 ml-4 list-inside list-disc">
|
||||
<li>
|
||||
<Trans>Total rows processed: {totalProcessed}</Trans>
|
||||
</li>
|
||||
<li className="mt-1">
|
||||
<Trans>Successfully created: {successCount}</Trans>
|
||||
</li>
|
||||
<li className="mt-1">
|
||||
<Trans>Failed: {failedCount}</Trans>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{failedCount > 0 && (
|
||||
<Section className="mt-4">
|
||||
<Text className="text-lg font-semibold">
|
||||
<Trans>The following errors occurred:</Trans>
|
||||
</Text>
|
||||
|
||||
<ul className="my-2 ml-4 list-inside list-disc">
|
||||
{errors.map((error, index) => (
|
||||
<li key={index} className="text-destructive mt-1 text-sm text-slate-400">
|
||||
{error}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Text className="text-sm">
|
||||
<Trans>
|
||||
You can view the created documents in your dashboard under the "Documents created
|
||||
from template" section.
|
||||
</Trans>
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
|
||||
<Container className="mx-auto max-w-xl">
|
||||
<TemplateFooter isDocument={false} />
|
||||
</Container>
|
||||
</Section>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
@ -6,6 +6,7 @@ import { SEND_SIGNING_EMAIL_JOB_DEFINITION } from './definitions/emails/send-sig
|
||||
import { SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-deleted-email';
|
||||
import { SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-member-joined-email';
|
||||
import { SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-member-left-email';
|
||||
import { BULK_SEND_TEMPLATE_JOB_DEFINITION } from './definitions/internal/bulk-send-template';
|
||||
import { SEAL_DOCUMENT_JOB_DEFINITION } from './definitions/internal/seal-document';
|
||||
|
||||
/**
|
||||
@ -21,6 +22,7 @@ export const jobsClient = new JobClient([
|
||||
SEAL_DOCUMENT_JOB_DEFINITION,
|
||||
SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION,
|
||||
SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION,
|
||||
BULK_SEND_TEMPLATE_JOB_DEFINITION,
|
||||
] as const);
|
||||
|
||||
export const jobs = jobsClient;
|
||||
|
||||
@ -0,0 +1,39 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZRequestMetadataSchema } from '../../../universal/extract-request-metadata';
|
||||
import { type JobDefinition } from '../../client/_internal/job';
|
||||
|
||||
const SEND_BULK_COMPLETE_EMAIL_JOB_DEFINITION_ID = 'send.bulk.complete.email';
|
||||
|
||||
const SEND_BULK_COMPLETE_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
|
||||
userId: z.number(),
|
||||
templateId: z.number(),
|
||||
templateName: z.string(),
|
||||
totalProcessed: z.number(),
|
||||
successCount: z.number(),
|
||||
failedCount: z.number(),
|
||||
errors: z.array(z.string()),
|
||||
requestMetadata: ZRequestMetadataSchema.optional(),
|
||||
});
|
||||
|
||||
export type TSendBulkCompleteEmailJobDefinition = z.infer<
|
||||
typeof SEND_BULK_COMPLETE_EMAIL_JOB_DEFINITION_SCHEMA
|
||||
>;
|
||||
|
||||
export const SEND_BULK_COMPLETE_EMAIL_JOB_DEFINITION = {
|
||||
id: SEND_BULK_COMPLETE_EMAIL_JOB_DEFINITION_ID,
|
||||
name: 'Send Bulk Complete Email',
|
||||
version: '1.0.0',
|
||||
trigger: {
|
||||
name: SEND_BULK_COMPLETE_EMAIL_JOB_DEFINITION_ID,
|
||||
schema: SEND_BULK_COMPLETE_EMAIL_JOB_DEFINITION_SCHEMA,
|
||||
},
|
||||
handler: async ({ payload, io }) => {
|
||||
const handler = await import('./send-bulk-complete-email.handler');
|
||||
|
||||
await handler.run({ payload, io });
|
||||
},
|
||||
} as const satisfies JobDefinition<
|
||||
typeof SEND_BULK_COMPLETE_EMAIL_JOB_DEFINITION_ID,
|
||||
TSendBulkCompleteEmailJobDefinition
|
||||
>;
|
||||
@ -0,0 +1,208 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { msg } from '@lingui/macro';
|
||||
import { parse } from 'csv-parse/sync';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { BulkSendCompleteEmail } from '@documenso/email/templates/bulk-send-complete';
|
||||
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
|
||||
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
|
||||
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { TeamGlobalSettings } from '@documenso/prisma/client';
|
||||
|
||||
import { getI18nInstance } from '../../../client-only/providers/i18n.server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
|
||||
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
|
||||
import { AppError } from '../../../errors/app-error';
|
||||
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
|
||||
import { teamGlobalSettingsToBranding } from '../../../utils/team-global-settings-to-branding';
|
||||
import type { JobRunIO } from '../../client/_internal/job';
|
||||
import type { TBulkSendTemplateJobDefinition } from './bulk-send-template';
|
||||
|
||||
const ZRecipientRowSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
email: z.union([
|
||||
z.string().email({ message: 'Value must be a valid email or empty string' }),
|
||||
z.string().max(0, { message: 'Value must be a valid email or empty string' }),
|
||||
]),
|
||||
});
|
||||
|
||||
export const run = async ({
|
||||
payload,
|
||||
io,
|
||||
}: {
|
||||
payload: TBulkSendTemplateJobDefinition;
|
||||
io: JobRunIO;
|
||||
}) => {
|
||||
const { userId, teamId, templateId, csvContent, sendImmediately, requestMetadata } = payload;
|
||||
|
||||
const template = await getTemplateById({
|
||||
id: templateId,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
if (!template) {
|
||||
throw new Error('Template not found');
|
||||
}
|
||||
|
||||
const rows = parse(csvContent, { columns: true, skip_empty_lines: true });
|
||||
|
||||
if (rows.length > 100) {
|
||||
throw new Error('Maximum 100 rows allowed per upload');
|
||||
}
|
||||
|
||||
const { recipients } = template;
|
||||
|
||||
// Validate CSV structure
|
||||
const csvHeaders = Object.keys(rows[0]);
|
||||
const requiredHeaders = recipients.map((_, index) => `recipient_${index + 1}_email`);
|
||||
|
||||
for (const header of requiredHeaders) {
|
||||
if (!csvHeaders.includes(header)) {
|
||||
throw new Error(`Missing required column: ${header}`);
|
||||
}
|
||||
}
|
||||
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
|
||||
const results = {
|
||||
success: 0,
|
||||
failed: 0,
|
||||
errors: Array<string>(),
|
||||
};
|
||||
|
||||
// Process each row
|
||||
for (const [rowIndex, row] of rows.entries()) {
|
||||
try {
|
||||
for (const [recipientIndex] of recipients.entries()) {
|
||||
const nameKey = `recipient_${recipientIndex + 1}_name`;
|
||||
const emailKey = `recipient_${recipientIndex + 1}_email`;
|
||||
|
||||
const parsed = ZRecipientRowSchema.safeParse({
|
||||
name: row[nameKey],
|
||||
email: row[emailKey],
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
throw new Error(
|
||||
`Invalid recipient data provided for ${emailKey}, ${nameKey}: ${parsed.error.issues?.[0]?.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const document = await io.runTask(`create-document-${rowIndex}`, async () => {
|
||||
return await createDocumentFromTemplate({
|
||||
templateId: template.id,
|
||||
userId,
|
||||
teamId,
|
||||
recipients: recipients.map((recipient, index) => {
|
||||
return {
|
||||
id: recipient.id,
|
||||
email: row[`recipient_${index + 1}_email`] || recipient.email,
|
||||
name: row[`recipient_${index + 1}_name`] || recipient.name,
|
||||
role: recipient.role,
|
||||
signingOrder: recipient.signingOrder,
|
||||
};
|
||||
}),
|
||||
requestMetadata: {
|
||||
source: 'app',
|
||||
auth: 'session',
|
||||
requestMetadata: requestMetadata || {},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
if (sendImmediately) {
|
||||
await io.runTask(`send-document-${rowIndex}`, async () => {
|
||||
await sendDocument({
|
||||
documentId: document.id,
|
||||
userId,
|
||||
teamId,
|
||||
requestMetadata: {
|
||||
source: 'app',
|
||||
auth: 'session',
|
||||
requestMetadata: requestMetadata || {},
|
||||
},
|
||||
}).catch((err) => {
|
||||
console.error(err);
|
||||
|
||||
throw new AppError('DOCUMENT_SEND_FAILED');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
results.success += 1;
|
||||
} catch (error) {
|
||||
results.failed += 1;
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
results.errors.push(`Row ${rowIndex + 1}: Was unable to be processed - ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
await io.runTask('send-completion-email', async () => {
|
||||
const completionTemplate = createElement(BulkSendCompleteEmail, {
|
||||
userName: user.name || user.email,
|
||||
templateName: template.title,
|
||||
totalProcessed: rows.length,
|
||||
successCount: results.success,
|
||||
failedCount: results.failed,
|
||||
errors: results.errors,
|
||||
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
|
||||
});
|
||||
|
||||
let teamGlobalSettings: TeamGlobalSettings | undefined | null;
|
||||
|
||||
if (template.teamId) {
|
||||
teamGlobalSettings = await prisma.teamGlobalSettings.findUnique({
|
||||
where: {
|
||||
teamId: template.teamId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const branding = teamGlobalSettings
|
||||
? teamGlobalSettingsToBranding(teamGlobalSettings)
|
||||
: undefined;
|
||||
|
||||
const i18n = await getI18nInstance(teamGlobalSettings?.documentLanguage);
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(completionTemplate, {
|
||||
lang: teamGlobalSettings?.documentLanguage,
|
||||
branding,
|
||||
}),
|
||||
renderEmailWithI18N(completionTemplate, {
|
||||
lang: teamGlobalSettings?.documentLanguage,
|
||||
branding,
|
||||
plainText: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
name: user.name || '',
|
||||
address: user.email,
|
||||
},
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: i18n._(msg`Bulk Send Complete: ${template.title}`),
|
||||
html,
|
||||
text,
|
||||
});
|
||||
});
|
||||
};
|
||||
37
packages/lib/jobs/definitions/internal/bulk-send-template.ts
Normal file
37
packages/lib/jobs/definitions/internal/bulk-send-template.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZRequestMetadataSchema } from '../../../universal/extract-request-metadata';
|
||||
import { type JobDefinition } from '../../client/_internal/job';
|
||||
|
||||
const BULK_SEND_TEMPLATE_JOB_DEFINITION_ID = 'internal.bulk-send-template';
|
||||
|
||||
const BULK_SEND_TEMPLATE_JOB_DEFINITION_SCHEMA = z.object({
|
||||
userId: z.number(),
|
||||
teamId: z.number().optional(),
|
||||
templateId: z.number(),
|
||||
csvContent: z.string(),
|
||||
sendImmediately: z.boolean(),
|
||||
requestMetadata: ZRequestMetadataSchema.optional(),
|
||||
});
|
||||
|
||||
export type TBulkSendTemplateJobDefinition = z.infer<
|
||||
typeof BULK_SEND_TEMPLATE_JOB_DEFINITION_SCHEMA
|
||||
>;
|
||||
|
||||
export const BULK_SEND_TEMPLATE_JOB_DEFINITION = {
|
||||
id: BULK_SEND_TEMPLATE_JOB_DEFINITION_ID,
|
||||
name: 'Bulk Send Template',
|
||||
version: '1.0.0',
|
||||
trigger: {
|
||||
name: BULK_SEND_TEMPLATE_JOB_DEFINITION_ID,
|
||||
schema: BULK_SEND_TEMPLATE_JOB_DEFINITION_SCHEMA,
|
||||
},
|
||||
handler: async ({ payload, io }) => {
|
||||
const handler = await import('./bulk-send-template.handler');
|
||||
|
||||
await handler.run({ payload, io });
|
||||
},
|
||||
} as const satisfies JobDefinition<
|
||||
typeof BULK_SEND_TEMPLATE_JOB_DEFINITION_ID,
|
||||
TBulkSendTemplateJobDefinition
|
||||
>;
|
||||
@ -40,6 +40,7 @@
|
||||
"@trigger.dev/sdk": "^2.3.18",
|
||||
"@upstash/redis": "^1.20.6",
|
||||
"@vvo/tzdb": "^6.117.0",
|
||||
"csv-parse": "^5.6.0",
|
||||
"inngest": "^3.19.13",
|
||||
"kysely": "^0.26.3",
|
||||
"luxon": "^3.4.0",
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, Prisma } from '@documenso/prisma/client';
|
||||
import { kyselyPrisma, sql } from '@documenso/prisma';
|
||||
import { DocumentStatus, SubscriptionStatus } from '@documenso/prisma/client';
|
||||
|
||||
export type SigningVolume = {
|
||||
id: number;
|
||||
@ -24,92 +24,78 @@ export async function getSigningVolume({
|
||||
sortBy = 'signingVolume',
|
||||
sortOrder = 'desc',
|
||||
}: GetSigningVolumeOptions) {
|
||||
const whereClause = Prisma.validator<Prisma.SubscriptionWhereInput>()({
|
||||
status: 'ACTIVE',
|
||||
OR: [
|
||||
{
|
||||
user: {
|
||||
OR: [
|
||||
{ name: { contains: search, mode: 'insensitive' } },
|
||||
{ email: { contains: search, mode: 'insensitive' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
team: {
|
||||
name: { contains: search, mode: 'insensitive' },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const offset = Math.max(page - 1, 0) * perPage;
|
||||
|
||||
const [subscriptions, totalCount] = await Promise.all([
|
||||
prisma.subscription.findMany({
|
||||
where: whereClause,
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
name: true,
|
||||
email: true,
|
||||
documents: {
|
||||
where: {
|
||||
status: DocumentStatus.COMPLETED,
|
||||
deletedAt: null,
|
||||
teamId: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
team: {
|
||||
select: {
|
||||
name: true,
|
||||
documents: {
|
||||
where: {
|
||||
status: DocumentStatus.COMPLETED,
|
||||
deletedAt: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy:
|
||||
sortBy === 'name'
|
||||
? [{ user: { name: sortOrder } }, { team: { name: sortOrder } }, { createdAt: 'desc' }]
|
||||
: sortBy === 'createdAt'
|
||||
? [{ createdAt: sortOrder }]
|
||||
: undefined,
|
||||
skip: Math.max(page - 1, 0) * perPage,
|
||||
take: perPage,
|
||||
}),
|
||||
prisma.subscription.count({
|
||||
where: whereClause,
|
||||
}),
|
||||
]);
|
||||
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 leaderboardWithVolume: SigningVolume[] = subscriptions.map((subscription) => {
|
||||
const name =
|
||||
subscription.user?.name || subscription.team?.name || subscription.user?.email || 'Unknown';
|
||||
const userSignedDocs = subscription.user?.documents?.length || 0;
|
||||
const teamSignedDocs = subscription.team?.documents?.length || 0;
|
||||
return {
|
||||
id: subscription.id,
|
||||
name,
|
||||
signingVolume: userSignedDocs + teamSignedDocs,
|
||||
createdAt: subscription.createdAt,
|
||||
planId: subscription.planId,
|
||||
};
|
||||
});
|
||||
|
||||
if (sortBy === 'signingVolume') {
|
||||
leaderboardWithVolume.sort((a, b) => {
|
||||
return sortOrder === 'desc'
|
||||
? b.signingVolume - a.signingVolume
|
||||
: a.signingVolume - b.signingVolume;
|
||||
});
|
||||
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');
|
||||
}
|
||||
|
||||
findQuery = findQuery.limit(perPage).offset(offset);
|
||||
|
||||
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 [results, [{ count }]] = await Promise.all([findQuery.execute(), countQuery.execute()]);
|
||||
|
||||
return {
|
||||
leaderboard: leaderboardWithVolume,
|
||||
totalPages: Math.ceil(totalCount / perPage),
|
||||
leaderboard: results,
|
||||
totalPages: Math.ceil(Number(count) / perPage),
|
||||
};
|
||||
}
|
||||
|
||||
@ -15,7 +15,7 @@ import type {
|
||||
TeamGlobalSettings,
|
||||
User,
|
||||
} from '@documenso/prisma/client';
|
||||
import { DocumentStatus, SendStatus } from '@documenso/prisma/client';
|
||||
import { DocumentStatus, SendStatus, WebhookTriggerEvents } from '@documenso/prisma/client';
|
||||
|
||||
import { getI18nInstance } from '../../client-only/providers/i18n.server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
@ -23,10 +23,15 @@ import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
||||
import {
|
||||
ZWebhookDocumentSchema,
|
||||
mapDocumentToWebhookDocumentPayload,
|
||||
} from '../../types/webhook-payload';
|
||||
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
|
||||
export type DeleteDocumentOptions = {
|
||||
id: number;
|
||||
@ -112,6 +117,13 @@ export const deleteDocument = async ({
|
||||
});
|
||||
}
|
||||
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_CANCELLED,
|
||||
data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(document)),
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
// Return partial document for API v1 response.
|
||||
return {
|
||||
id: document.id,
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
// https://github.com/Hopding/pdf-lib/issues/20#issuecomment-412852821
|
||||
import fontkit from '@pdf-lib/fontkit';
|
||||
import type { PDFDocument } from 'pdf-lib';
|
||||
import { RotationTypes, degrees, radiansToDegrees } from 'pdf-lib';
|
||||
import { RotationTypes, degrees, radiansToDegrees, rgb } from 'pdf-lib';
|
||||
import { P, match } from 'ts-pattern';
|
||||
|
||||
import {
|
||||
@ -36,6 +36,9 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
|
||||
);
|
||||
|
||||
const isSignatureField = isSignatureFieldType(field.type);
|
||||
const isDebugMode =
|
||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
||||
process.env.DEBUG_PDF_INSERT === '1' || process.env.DEBUG_PDF_INSERT === 'true';
|
||||
|
||||
pdf.registerFontkit(fontkit);
|
||||
|
||||
@ -83,6 +86,35 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
|
||||
const fieldX = pageWidth * (Number(field.positionX) / 100);
|
||||
const fieldY = pageHeight * (Number(field.positionY) / 100);
|
||||
|
||||
// Draw debug box if debug mode is enabled
|
||||
if (isDebugMode) {
|
||||
let debugX = fieldX;
|
||||
let debugY = pageHeight - fieldY - fieldHeight; // Invert Y for PDF coordinates
|
||||
|
||||
if (pageRotationInDegrees !== 0) {
|
||||
const adjustedPosition = adjustPositionForRotation(
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
debugX,
|
||||
debugY,
|
||||
pageRotationInDegrees,
|
||||
);
|
||||
|
||||
debugX = adjustedPosition.xPos;
|
||||
debugY = adjustedPosition.yPos;
|
||||
}
|
||||
|
||||
page.drawRectangle({
|
||||
x: debugX,
|
||||
y: debugY,
|
||||
width: fieldWidth,
|
||||
height: fieldHeight,
|
||||
borderColor: rgb(1, 0, 0), // Red
|
||||
borderWidth: 1,
|
||||
rotate: degrees(pageRotationInDegrees),
|
||||
});
|
||||
}
|
||||
|
||||
const font = await pdf.embedFont(
|
||||
isSignatureField ? fontCaveat : fontNoto,
|
||||
isSignatureField ? { features: { calt: false } } : undefined,
|
||||
@ -278,6 +310,7 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
|
||||
const meta = Parser ? Parser.safeParse(field.fieldMeta) : null;
|
||||
|
||||
const customFontSize = meta?.success && meta.data.fontSize ? meta.data.fontSize : null;
|
||||
const textAlign = meta?.success && meta.data.textAlign ? meta.data.textAlign : 'center';
|
||||
const longestLineInTextForWidth = field.customText
|
||||
.split('\n')
|
||||
.sort((a, b) => b.length - a.length)[0];
|
||||
@ -293,7 +326,17 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
|
||||
|
||||
textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
|
||||
|
||||
let textX = fieldX + (fieldWidth - textWidth) / 2;
|
||||
// Add padding similar to web display (roughly 0.5rem equivalent in PDF units)
|
||||
const padding = 8; // PDF points, roughly equivalent to 0.5rem
|
||||
|
||||
// Calculate X position based on text alignment with padding
|
||||
let textX = fieldX + padding; // Left alignment starts after padding
|
||||
if (textAlign === 'center') {
|
||||
textX = fieldX + (fieldWidth - textWidth) / 2; // Center alignment ignores padding
|
||||
} else if (textAlign === 'right') {
|
||||
textX = fieldX + fieldWidth - textWidth - padding; // Right alignment respects right padding
|
||||
}
|
||||
|
||||
let textY = fieldY + (fieldHeight - textHeight) / 2;
|
||||
|
||||
// Invert the Y axis since PDFs use a bottom-left coordinate system
|
||||
|
||||
@ -95,7 +95,7 @@ export const createTeam = async ({
|
||||
});
|
||||
}
|
||||
|
||||
await tx.team.create({
|
||||
const team = await tx.team.create({
|
||||
data: {
|
||||
name: teamName,
|
||||
url: teamUrl,
|
||||
@ -104,13 +104,23 @@ export const createTeam = async ({
|
||||
members: {
|
||||
create: [
|
||||
{
|
||||
userId,
|
||||
userId: user.id,
|
||||
role: TeamMemberRole.ADMIN,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await tx.teamGlobalSettings.upsert({
|
||||
where: {
|
||||
teamId: team.id,
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
teamId: team.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
@ -225,6 +235,16 @@ export const createTeamFromPendingTeam = async ({
|
||||
},
|
||||
});
|
||||
|
||||
await tx.teamGlobalSettings.upsert({
|
||||
where: {
|
||||
teamId: team.id,
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
teamId: team.id,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.subscription.upsert(
|
||||
mapStripeSubscriptionToPrismaUpsertAction(subscription, undefined, team.id),
|
||||
);
|
||||
|
||||
@ -11,9 +11,14 @@ export const ZBaseFieldMeta = z.object({
|
||||
|
||||
export type TBaseFieldMeta = z.infer<typeof ZBaseFieldMeta>;
|
||||
|
||||
export const ZFieldTextAlignSchema = z.enum(['left', 'center', 'right']);
|
||||
|
||||
export type TFieldTextAlignSchema = z.infer<typeof ZFieldTextAlignSchema>;
|
||||
|
||||
export const ZInitialsFieldMeta = ZBaseFieldMeta.extend({
|
||||
type: z.literal('initials'),
|
||||
fontSize: z.number().min(8).max(96).optional(),
|
||||
textAlign: ZFieldTextAlignSchema.optional(),
|
||||
});
|
||||
|
||||
export type TInitialsFieldMeta = z.infer<typeof ZInitialsFieldMeta>;
|
||||
@ -21,6 +26,7 @@ export type TInitialsFieldMeta = z.infer<typeof ZInitialsFieldMeta>;
|
||||
export const ZNameFieldMeta = ZBaseFieldMeta.extend({
|
||||
type: z.literal('name'),
|
||||
fontSize: z.number().min(8).max(96).optional(),
|
||||
textAlign: ZFieldTextAlignSchema.optional(),
|
||||
});
|
||||
|
||||
export type TNameFieldMeta = z.infer<typeof ZNameFieldMeta>;
|
||||
@ -28,6 +34,7 @@ export type TNameFieldMeta = z.infer<typeof ZNameFieldMeta>;
|
||||
export const ZEmailFieldMeta = ZBaseFieldMeta.extend({
|
||||
type: z.literal('email'),
|
||||
fontSize: z.number().min(8).max(96).optional(),
|
||||
textAlign: ZFieldTextAlignSchema.optional(),
|
||||
});
|
||||
|
||||
export type TEmailFieldMeta = z.infer<typeof ZEmailFieldMeta>;
|
||||
@ -35,6 +42,7 @@ export type TEmailFieldMeta = z.infer<typeof ZEmailFieldMeta>;
|
||||
export const ZDateFieldMeta = ZBaseFieldMeta.extend({
|
||||
type: z.literal('date'),
|
||||
fontSize: z.number().min(8).max(96).optional(),
|
||||
textAlign: ZFieldTextAlignSchema.optional(),
|
||||
});
|
||||
|
||||
export type TDateFieldMeta = z.infer<typeof ZDateFieldMeta>;
|
||||
@ -44,6 +52,7 @@ export const ZTextFieldMeta = ZBaseFieldMeta.extend({
|
||||
text: z.string().optional(),
|
||||
characterLimit: z.number().optional(),
|
||||
fontSize: z.number().min(8).max(96).optional(),
|
||||
textAlign: ZFieldTextAlignSchema.optional(),
|
||||
});
|
||||
|
||||
export type TTextFieldMeta = z.infer<typeof ZTextFieldMeta>;
|
||||
@ -55,6 +64,7 @@ export const ZNumberFieldMeta = ZBaseFieldMeta.extend({
|
||||
minValue: z.number().optional(),
|
||||
maxValue: z.number().optional(),
|
||||
fontSize: z.number().min(8).max(96).optional(),
|
||||
textAlign: ZFieldTextAlignSchema.optional(),
|
||||
});
|
||||
|
||||
export type TNumberFieldMeta = z.infer<typeof ZNumberFieldMeta>;
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'DOCUMENT_CANCELLED';
|
||||
@ -175,6 +175,7 @@ enum WebhookTriggerEvents {
|
||||
DOCUMENT_SIGNED
|
||||
DOCUMENT_COMPLETED
|
||||
DOCUMENT_REJECTED
|
||||
DOCUMENT_CANCELLED
|
||||
}
|
||||
|
||||
model Webhook {
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
|
||||
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { jobs } from '@documenso/lib/jobs/client';
|
||||
import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
|
||||
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
|
||||
import {
|
||||
@ -25,6 +28,7 @@ import type { Document } from '@documenso/prisma/client';
|
||||
import { ZGenericSuccessResponse, ZSuccessResponseSchema } from '../document-router/schema';
|
||||
import { authenticatedProcedure, maybeAuthenticatedProcedure, router } from '../trpc';
|
||||
import {
|
||||
ZBulkSendTemplateMutationSchema,
|
||||
ZCreateDocumentFromDirectTemplateRequestSchema,
|
||||
ZCreateDocumentFromTemplateRequestSchema,
|
||||
ZCreateDocumentFromTemplateResponseSchema,
|
||||
@ -414,4 +418,48 @@ export const templateRouter = router({
|
||||
userId,
|
||||
});
|
||||
}),
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
uploadBulkSend: authenticatedProcedure
|
||||
.input(ZBulkSendTemplateMutationSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { templateId, teamId, csv, sendImmediately } = input;
|
||||
const { user } = ctx;
|
||||
|
||||
if (csv.length > 4 * 1024 * 1024) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'File size exceeds 4MB limit',
|
||||
});
|
||||
}
|
||||
|
||||
const template = await getTemplateById({
|
||||
id: templateId,
|
||||
teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
if (!template) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Template not found',
|
||||
});
|
||||
}
|
||||
|
||||
await jobs.triggerJob({
|
||||
name: 'internal.bulk-send-template',
|
||||
payload: {
|
||||
userId: user.id,
|
||||
teamId,
|
||||
templateId,
|
||||
csvContent: csv,
|
||||
sendImmediately,
|
||||
requestMetadata: ctx.metadata.requestMetadata,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
|
||||
@ -183,6 +183,14 @@ export const ZMoveTemplateToTeamRequestSchema = z.object({
|
||||
|
||||
export const ZMoveTemplateToTeamResponseSchema = ZTemplateLiteSchema;
|
||||
|
||||
export const ZBulkSendTemplateMutationSchema = z.object({
|
||||
templateId: z.number(),
|
||||
teamId: z.number().optional(),
|
||||
csv: z.string().min(1),
|
||||
sendImmediately: z.boolean(),
|
||||
});
|
||||
|
||||
export type TCreateTemplateMutationSchema = z.infer<typeof ZCreateTemplateMutationSchema>;
|
||||
export type TDuplicateTemplateMutationSchema = z.infer<typeof ZDuplicateTemplateMutationSchema>;
|
||||
export type TDeleteTemplateMutationSchema = z.infer<typeof ZDeleteTemplateMutationSchema>;
|
||||
export type TBulkSendTemplateMutationSchema = z.infer<typeof ZBulkSendTemplateMutationSchema>;
|
||||
|
||||
@ -71,21 +71,25 @@ const getDefaultState = (fieldType: FieldType): FieldMeta => {
|
||||
return {
|
||||
type: 'initials',
|
||||
fontSize: 14,
|
||||
textAlign: 'left',
|
||||
};
|
||||
case FieldType.NAME:
|
||||
return {
|
||||
type: 'name',
|
||||
fontSize: 14,
|
||||
textAlign: 'left',
|
||||
};
|
||||
case FieldType.EMAIL:
|
||||
return {
|
||||
type: 'email',
|
||||
fontSize: 14,
|
||||
textAlign: 'left',
|
||||
};
|
||||
case FieldType.DATE:
|
||||
return {
|
||||
type: 'date',
|
||||
fontSize: 14,
|
||||
textAlign: 'left',
|
||||
};
|
||||
case FieldType.TEXT:
|
||||
return {
|
||||
@ -97,6 +101,7 @@ const getDefaultState = (fieldType: FieldType): FieldMeta => {
|
||||
fontSize: 14,
|
||||
required: false,
|
||||
readOnly: false,
|
||||
textAlign: 'left',
|
||||
};
|
||||
case FieldType.NUMBER:
|
||||
return {
|
||||
@ -110,6 +115,7 @@ const getDefaultState = (fieldType: FieldType): FieldMeta => {
|
||||
required: false,
|
||||
readOnly: false,
|
||||
fontSize: 14,
|
||||
textAlign: 'left',
|
||||
};
|
||||
case FieldType.RADIO:
|
||||
return {
|
||||
|
||||
@ -5,6 +5,13 @@ import { validateFields as validateDateFields } from '@documenso/lib/advanced-fi
|
||||
import { type TDateFieldMeta as DateFieldMeta } from '@documenso/lib/types/field-meta';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
|
||||
type DateFieldAdvancedSettingsProps = {
|
||||
fieldState: DateFieldMeta;
|
||||
@ -66,6 +73,27 @@ export const DateFieldAdvancedSettings = ({
|
||||
max={96}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>
|
||||
<Trans>Text Align</Trans>
|
||||
</Label>
|
||||
|
||||
<Select
|
||||
value={fieldState.textAlign}
|
||||
onValueChange={(value) => handleInput('textAlign', value)}
|
||||
>
|
||||
<SelectTrigger className="bg-background mt-2">
|
||||
<SelectValue placeholder="Select text align" />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectItem value="left">Left</SelectItem>
|
||||
<SelectItem value="center">Center</SelectItem>
|
||||
<SelectItem value="right">Right</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -5,6 +5,13 @@ import { validateFields as validateEmailFields } from '@documenso/lib/advanced-f
|
||||
import { type TEmailFieldMeta as EmailFieldMeta } from '@documenso/lib/types/field-meta';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
|
||||
type EmailFieldAdvancedSettingsProps = {
|
||||
fieldState: EmailFieldMeta;
|
||||
@ -48,6 +55,27 @@ export const EmailFieldAdvancedSettings = ({
|
||||
max={96}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>
|
||||
<Trans>Text Align</Trans>
|
||||
</Label>
|
||||
|
||||
<Select
|
||||
value={fieldState.textAlign}
|
||||
onValueChange={(value) => handleInput('textAlign', value)}
|
||||
>
|
||||
<SelectTrigger className="bg-background mt-2">
|
||||
<SelectValue placeholder="Select text align" />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectItem value="left">Left</SelectItem>
|
||||
<SelectItem value="center">Center</SelectItem>
|
||||
<SelectItem value="right">Right</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -6,6 +6,8 @@ import { type TInitialsFieldMeta as InitialsFieldMeta } from '@documenso/lib/typ
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../select';
|
||||
|
||||
type InitialsFieldAdvancedSettingsProps = {
|
||||
fieldState: InitialsFieldMeta;
|
||||
handleFieldChange: (key: keyof InitialsFieldMeta, value: string | boolean) => void;
|
||||
@ -48,6 +50,27 @@ export const InitialsFieldAdvancedSettings = ({
|
||||
max={96}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>
|
||||
<Trans>Text Align</Trans>
|
||||
</Label>
|
||||
|
||||
<Select
|
||||
value={fieldState.textAlign}
|
||||
onValueChange={(value) => handleInput('textAlign', value)}
|
||||
>
|
||||
<SelectTrigger className="bg-background mt-2">
|
||||
<SelectValue placeholder="Select text align" />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectItem value="left">Left</SelectItem>
|
||||
<SelectItem value="center">Center</SelectItem>
|
||||
<SelectItem value="right">Right</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -5,6 +5,13 @@ import { validateFields as validateNameFields } from '@documenso/lib/advanced-fi
|
||||
import { type TNameFieldMeta as NameFieldMeta } from '@documenso/lib/types/field-meta';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
|
||||
type NameFieldAdvancedSettingsProps = {
|
||||
fieldState: NameFieldMeta;
|
||||
@ -48,6 +55,27 @@ export const NameFieldAdvancedSettings = ({
|
||||
max={96}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>
|
||||
<Trans>Text Align</Trans>
|
||||
</Label>
|
||||
|
||||
<Select
|
||||
value={fieldState.textAlign}
|
||||
onValueChange={(value) => handleInput('textAlign', value)}
|
||||
>
|
||||
<SelectTrigger className="bg-background mt-2">
|
||||
<SelectValue placeholder="Select text align" />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectItem value="left">Left</SelectItem>
|
||||
<SelectItem value="center">Center</SelectItem>
|
||||
<SelectItem value="right">Right</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -38,12 +38,12 @@ export const NumberFieldAdvancedSettings = ({
|
||||
const [showValidation, setShowValidation] = useState(false);
|
||||
|
||||
const handleInput = (field: keyof NumberFieldMeta, value: string | boolean) => {
|
||||
const userValue = field === 'value' ? value : fieldState.value ?? 0;
|
||||
const userValue = field === 'value' ? value : (fieldState.value ?? 0);
|
||||
const userMinValue = field === 'minValue' ? Number(value) : Number(fieldState.minValue ?? 0);
|
||||
const userMaxValue = field === 'maxValue' ? Number(value) : Number(fieldState.maxValue ?? 0);
|
||||
const readOnly = field === 'readOnly' ? Boolean(value) : Boolean(fieldState.readOnly);
|
||||
const required = field === 'required' ? Boolean(value) : Boolean(fieldState.required);
|
||||
const numberFormat = field === 'numberFormat' ? String(value) : fieldState.numberFormat ?? '';
|
||||
const numberFormat = field === 'numberFormat' ? String(value) : (fieldState.numberFormat ?? '');
|
||||
const fontSize = field === 'fontSize' ? Number(value) : Number(fieldState.fontSize ?? 14);
|
||||
|
||||
const valueErrors = validateNumberField(String(userValue), {
|
||||
@ -135,6 +135,27 @@ export const NumberFieldAdvancedSettings = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>
|
||||
<Trans>Text Align</Trans>
|
||||
</Label>
|
||||
|
||||
<Select
|
||||
value={fieldState.textAlign}
|
||||
onValueChange={(value) => handleInput('textAlign', value)}
|
||||
>
|
||||
<SelectTrigger className="bg-background mt-2">
|
||||
<SelectValue placeholder="Select text align" />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectItem value="left">Left</SelectItem>
|
||||
<SelectItem value="center">Center</SelectItem>
|
||||
<SelectItem value="right">Right</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-col gap-4">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<Switch
|
||||
|
||||
@ -5,6 +5,13 @@ import { validateTextField } from '@documenso/lib/advanced-fields-validation/val
|
||||
import { type TTextFieldMeta as TextFieldMeta } from '@documenso/lib/types/field-meta';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
import { Switch } from '@documenso/ui/primitives/switch';
|
||||
import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||
|
||||
@ -22,7 +29,7 @@ export const TextFieldAdvancedSettings = ({
|
||||
const { _ } = useLingui();
|
||||
|
||||
const handleInput = (field: keyof TextFieldMeta, value: string | boolean) => {
|
||||
const text = field === 'text' ? String(value) : fieldState.text ?? '';
|
||||
const text = field === 'text' ? String(value) : (fieldState.text ?? '');
|
||||
const limit =
|
||||
field === 'characterLimit' ? Number(value) : Number(fieldState.characterLimit ?? 0);
|
||||
const fontSize = field === 'fontSize' ? Number(value) : Number(fieldState.fontSize ?? 14);
|
||||
@ -112,6 +119,27 @@ export const TextFieldAdvancedSettings = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>
|
||||
<Trans>Text Align</Trans>
|
||||
</Label>
|
||||
|
||||
<Select
|
||||
value={fieldState.textAlign}
|
||||
onValueChange={(value) => handleInput('textAlign', value)}
|
||||
>
|
||||
<SelectTrigger className="bg-background mt-2">
|
||||
<SelectValue placeholder="Select text align" />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectItem value="left">Left</SelectItem>
|
||||
<SelectItem value="center">Center</SelectItem>
|
||||
<SelectItem value="right">Right</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-col gap-4">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<Switch
|
||||
|
||||
Reference in New Issue
Block a user