Compare commits

...
Author SHA1 Message Date
Lucas Smith e7f053e6fd fix: send notification on limits change
Add a checkbox to notify the organisation owner when their
limits are updated.

Covers members, recipients, envelope items and quotas.
2026-09-30 16:14:18 +10:00
Raiyan Zaman b75a66d5c0 fix(i18n): add missing "zu" in German invite/reminder strings (#3331) 2026-09-29 21:25:30 +10:00
10 changed files with 289 additions and 23 deletions
@@ -37,7 +37,7 @@ import { useMemo } from 'react';
import { useForm } from 'react-hook-form';
import { Link, useNavigate } from 'react-router';
import { match } from 'ts-pattern';
import type { z } from 'zod';
import { z } from 'zod';
import { AdminOrganisationDeleteDialog } from '~/components/dialogs/admin-organisation-delete-dialog';
import { AdminOrganisationMemberDeleteDialog } from '~/components/dialogs/admin-organisation-member-delete-dialog';
@@ -563,11 +563,15 @@ const GenericOrganisationAdminForm = ({ organisation }: OrganisationAdminFormOpt
);
};
const ZUpdateOrganisationBillingFormSchema = ZUpdateAdminOrganisationRequestSchema.shape.data.pick({
claims: true,
customerId: true,
originalSubscriptionClaimId: true,
});
const ZUpdateOrganisationBillingFormSchema = ZUpdateAdminOrganisationRequestSchema.shape.data
.pick({
claims: true,
customerId: true,
originalSubscriptionClaimId: true,
})
.extend({
notifyOrganisation: z.boolean(),
});
type TUpdateOrganisationBillingFormSchema = z.infer<typeof ZUpdateOrganisationBillingFormSchema>;
@@ -614,19 +618,31 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
emailTransportId: organisation.organisationClaim.emailTransportId ?? null,
},
originalSubscriptionClaimId: organisation.organisationClaim.originalSubscriptionClaimId || '',
notifyOrganisation: true,
},
});
const onSubmit = async (values: TUpdateOrganisationBillingFormSchema) => {
const onSubmit = async ({ notifyOrganisation, ...values }: TUpdateOrganisationBillingFormSchema) => {
try {
await updateOrganisation({
const { isNotificationSent } = await updateOrganisation({
organisationId: organisation.id,
data: values,
notifyOrganisation,
});
form.setValue('notifyOrganisation', false);
const description = match({ notifyOrganisation, isNotificationSent })
.with({ isNotificationSent: true }, () => t`Organisation has been updated and its owner has been notified`)
.with(
{ notifyOrganisation: true },
() => t`Organisation has been updated. No limits changed, so no notification was sent`,
)
.otherwise(() => t`Organisation has been updated successfully`);
toast({
title: t`Success`,
description: t`Organisation has been updated successfully`,
description,
duration: 5000,
});
} catch (err) {
@@ -919,6 +935,31 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
)}
/>
<FormField
control={form.control}
name="notifyOrganisation"
render={({ field }) => (
<FormItem>
<div className="flex items-center">
<FormControl>
<Checkbox id="notify-organisation" checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<label className="ml-2 text-muted-foreground text-sm" htmlFor="notify-organisation">
<Trans>Notify the organisation about limit changes</Trans>
</label>
</div>
<FormDescription>
<Trans>
Emails the organisation owner to let them know their limits have been updated. No email is sent if no
limits changed.
</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end">
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Update</Trans>
@@ -0,0 +1,70 @@
import { SUPPORT_EMAIL } from '@documenso/lib/constants/app';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Body, Container, Head, Hr, Html, Preview, Section, Text } from '../components';
import { TemplateBrandingLogo } from '../template-components/template-branding-logo';
import { TemplateFooter } from '../template-components/template-footer';
export type OrganisationLimitsUpdatedEmailProps = {
assetBaseUrl: string;
organisationName: string;
};
export const OrganisationLimitsUpdatedEmailTemplate = ({
assetBaseUrl = 'http://localhost:3002',
organisationName = 'Organisation Name',
}: OrganisationLimitsUpdatedEmailProps) => {
const { _ } = useLingui();
return (
<Html>
<Head />
<Body className="mx-auto my-auto font-sans">
<Preview>{_(ORGANISATION_LIMITS_UPDATED_SUBJECT)}</Preview>
<Section className="bg-background text-muted-foreground">
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
<Section className="p-2 text-muted-foreground">
<Text className="text-center font-medium text-foreground text-lg">
{_(ORGANISATION_LIMITS_UPDATED_SUBJECT)}
</Text>
<div className="mx-auto my-2 w-fit rounded-lg bg-muted px-4 py-2 font-medium text-base text-muted-foreground">
{organisationName}
</div>
<Text className="text-center text-base">
<Trans>We've updated the usage limits for your organisation.</Trans>
</Text>
<Text className="text-center text-base">
<Trans>
If you recently received an email regarding your usage limits, this change was made in response to
that.
</Trans>
</Text>
<Text className="text-center text-base">
<Trans>If you have any questions, please contact support at {SUPPORT_EMAIL}.</Trans>
</Text>
</Section>
</Container>
<Hr className="mx-auto mt-12 max-w-xl" />
<Container className="mx-auto max-w-xl">
<TemplateFooter isDocument={false} />
</Container>
</Section>
</Body>
</Html>
);
};
export const ORGANISATION_LIMITS_UPDATED_SUBJECT = msg`Your organisation limits have been updated`;
export default OrganisationLimitsUpdatedEmailTemplate;
+2
View File
@@ -7,6 +7,7 @@ import { SEND_DOCUMENT_CREATED_FROM_DIRECT_TEMPLATE_EMAIL_JOB_DEFINITION } from
import { SEND_DOCUMENT_DELETED_EMAILS_JOB_DEFINITION } from './definitions/emails/send-document-deleted-emails';
import { SEND_DOCUMENT_PENDING_EMAIL_JOB_DEFINITION } from './definitions/emails/send-document-pending-email';
import { SEND_ORGANISATION_LIMIT_ALERT_EMAIL_JOB_DEFINITION } from './definitions/emails/send-organisation-limit-alert-email';
import { SEND_ORGANISATION_LIMITS_UPDATED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-organisation-limits-updated-email';
import { SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-organisation-member-joined-email';
import { SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION } from './definitions/emails/send-organisation-member-left-email';
import { SEND_OWNER_RECIPIENT_EXPIRED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-owner-recipient-expired-email';
@@ -43,6 +44,7 @@ export const jobsClient = new JobClient([
SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION,
SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION,
SEND_ORGANISATION_LIMIT_ALERT_EMAIL_JOB_DEFINITION,
SEND_ORGANISATION_LIMITS_UPDATED_EMAIL_JOB_DEFINITION,
SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION,
SEAL_DOCUMENT_JOB_DEFINITION,
SEAL_DOCUMENT_SWEEP_JOB_DEFINITION,
@@ -129,6 +129,7 @@ export const run = async ({
text: `
Organisation: ${organisation.name}
Organisation ID: ${organisation.id}
Admin URL: ${NEXT_PUBLIC_WEBAPP_URL()}/admin/organisations/${organisation.id}
Organisation Claim Original ID: ${organisation.organisationClaim.originalSubscriptionClaimId}
Email Quota: ${organisation.monthlyStats[0]?.emailCount || 0}/${organisation.organisationClaim.emailQuota ?? 'Unlimited'}
API Quota: ${organisation.monthlyStats[0]?.apiCount || 0}/${organisation.organisationClaim.apiQuota ?? 'Unlimited'}
@@ -0,0 +1,65 @@
import OrganisationLimitsUpdatedEmailTemplate, {
ORGANISATION_LIMITS_UPDATED_SUBJECT,
} from '@documenso/email/templates/organisation-limits-updated';
import { prisma } from '@documenso/prisma';
import { createElement } from 'react';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSendOrganisationLimitsUpdatedEmailJobDefinition } from './send-organisation-limits-updated-email';
export const run = async ({
payload,
io,
}: {
payload: TSendOrganisationLimitsUpdatedEmailJobDefinition;
io: JobRunIO;
}) => {
const organisation = await prisma.organisation.findFirstOrThrow({
where: {
id: payload.organisationId,
},
select: {
id: true,
name: true,
owner: {
select: {
email: true,
},
},
},
});
const { branding, emailLanguage, senderEmail, emailTransport } = await getEmailContext({
emailType: 'INTERNAL',
source: {
type: 'organisation',
organisationId: organisation.id,
},
});
await io.runTask('send-organisation-limits-updated-email', async () => {
const emailContent = createElement(OrganisationLimitsUpdatedEmailTemplate, {
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
organisationName: organisation.name,
});
const [html, text] = await Promise.all([
renderEmailWithI18N(emailContent, { lang: emailLanguage, branding }),
renderEmailWithI18N(emailContent, { lang: emailLanguage, branding, plainText: true }),
]);
const i18n = await getI18nInstance(emailLanguage);
await emailTransport.sendMail({
to: organisation.owner.email,
from: senderEmail,
subject: i18n._(ORGANISATION_LIMITS_UPDATED_SUBJECT),
html,
text,
});
});
};
@@ -0,0 +1,31 @@
import { z } from 'zod';
import type { JobDefinition } from '../../client/_internal/job';
const SEND_ORGANISATION_LIMITS_UPDATED_EMAIL_JOB_DEFINITION_ID = 'send.organisation-limits-updated.email';
const SEND_ORGANISATION_LIMITS_UPDATED_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
organisationId: z.string(),
});
export type TSendOrganisationLimitsUpdatedEmailJobDefinition = z.infer<
typeof SEND_ORGANISATION_LIMITS_UPDATED_EMAIL_JOB_DEFINITION_SCHEMA
>;
export const SEND_ORGANISATION_LIMITS_UPDATED_EMAIL_JOB_DEFINITION = {
id: SEND_ORGANISATION_LIMITS_UPDATED_EMAIL_JOB_DEFINITION_ID,
name: 'Send Organisation Limits Updated Email',
version: '1.0.0',
trigger: {
name: SEND_ORGANISATION_LIMITS_UPDATED_EMAIL_JOB_DEFINITION_ID,
schema: SEND_ORGANISATION_LIMITS_UPDATED_EMAIL_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const handler = await import('./send-organisation-limits-updated-email.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof SEND_ORGANISATION_LIMITS_UPDATED_EMAIL_JOB_DEFINITION_ID,
TSendOrganisationLimitsUpdatedEmailJobDefinition
>;
+12 -12
View File
@@ -310,7 +310,7 @@ msgstr "{0}-Feld"
#. placeholder {1}: envelope.title
#: packages/lib/jobs/definitions/emails/send-signing-email.handler.ts
msgid "{0} has invited you to {recipientActionVerb} the document \"{1}\"."
msgstr "{0} hat Sie eingeladen, das Dokument \"{1}\" {recipientActionVerb}."
msgstr "{0} hat Sie eingeladen, das Dokument \"{1}\" zu {recipientActionVerb}."
#. placeholder {0}: organisation.name
#: apps/remix/app/components/general/settings-upsell/branding-upsell.tsx
@@ -321,7 +321,7 @@ msgstr ""
#. placeholder {0}: team.name
#: packages/lib/jobs/definitions/emails/send-signing-email.handler.ts
msgid "{0} invited you to {recipientActionVerb} a document"
msgstr "{0} hat dich eingeladen, ein Dokument {recipientActionVerb}"
msgstr "{0} hat dich eingeladen, ein Dokument zu {recipientActionVerb}"
#. placeholder {0}: remaining.documents
#. placeholder {1}: quota.documents
@@ -336,7 +336,7 @@ msgstr "{0} von {1} Dokumenten verbleibend in diesem Monat."
#. placeholder {2}: envelope.title
#: packages/lib/server-only/document/resend-document.ts
msgid "{0} on behalf of \"{1}\" has invited you to {recipientActionVerb} the document \"{2}\"."
msgstr "{0} im Namen von \"{1}\" hat Sie eingeladen, das Dokument \"{2}\" {recipientActionVerb}."
msgstr "{0} im Namen von \"{1}\" hat Sie eingeladen, das Dokument \"{2}\" zu {recipientActionVerb}."
#. placeholder {0}: planClaim.name
#: apps/remix/app/components/general/settings-org-switcher.tsx
@@ -407,15 +407,15 @@ msgstr "{inviterName} hat das Dokument<0/>\"{documentName}\" storniert"
#. placeholder {0}: _(actionVerb).toLowerCase()
#: packages/email/template-components/template-document-invite.tsx
msgid "{inviterName} has invited you to {0}<0/>\"{documentName}\""
msgstr "{inviterName} hat dich eingeladen, {0}<0/>\"{documentName}\""
msgstr "{inviterName} hat dich eingeladen, zu {0}<0/>\"{documentName}\""
#: packages/email/templates/document-invite.tsx
msgid "{inviterName} has invited you to {action} {documentName}"
msgstr "{inviterName} hat dich eingeladen, {action} {documentName}"
msgstr "{inviterName} hat dich eingeladen, zu {action} {documentName}"
#: packages/email/templates/document-invite.tsx
msgid "{inviterName} has invited you to {action} the document \"{documentName}\"."
msgstr "{inviterName} hat Sie eingeladen, das Dokument \"{documentName}\" {action}."
msgstr "{inviterName} hat Sie eingeladen, das Dokument \"{documentName}\" zu {action}."
#: packages/email/templates/recipient-removed-from-document.tsx
msgid "{inviterName} has removed you from the document {documentName}."
@@ -429,16 +429,16 @@ msgstr "{inviterName} hat dich aus dem Dokument<0/>\"{documentName}\" entfernt"
#. placeholder {1}: envelope.title
#: packages/lib/jobs/definitions/emails/send-signing-email.handler.ts
msgid "{inviterName} on behalf of \"{0}\" has invited you to {recipientActionVerb} the document \"{1}\"."
msgstr "{inviterName} im Auftrag von \"{0}\" hat Sie eingeladen, das Dokument \"{1}\" {recipientActionVerb}."
msgstr "{inviterName} im Auftrag von \"{0}\" hat Sie eingeladen, das Dokument \"{1}\" zu {recipientActionVerb}."
#. placeholder {0}: _(actionVerb).toLowerCase()
#: packages/email/template-components/template-document-invite.tsx
msgid "{inviterName} on behalf of \"{teamName}\" has invited you to {0}<0/>\"{documentName}\""
msgstr "{inviterName} im Namen von \"{teamName}\" hat dich eingeladen, {0}<0/>\"{documentName}\""
msgstr "{inviterName} im Namen von \"{teamName}\" hat dich eingeladen, zu {0}<0/>\"{documentName}\""
#: packages/email/templates/document-invite.tsx
msgid "{inviterName} on behalf of \"{teamName}\" has invited you to {action} {documentName}"
msgstr "{inviterName} im Namen von \"{teamName}\" hat Sie eingeladen, das Dokument {documentName} {action}"
msgstr "{inviterName} im Namen von \"{teamName}\" hat Sie eingeladen, das Dokument {documentName} zu {action}"
#: apps/remix/app/components/dialogs/envelopes-bulk-download-dialog.tsx
msgid "{MAX_BULK_DOWNLOAD_ENVELOPES, plural, one {You can download up to # document at a time. Deselect some documents to continue.} other {You can download up to # documents at a time. Deselect some documents to continue.}}"
@@ -517,11 +517,11 @@ msgstr "{subscriptionClaimCount, plural, one {# Abonnementsanforderung} other {#
#. placeholder {0}: _(actionVerb).toLowerCase()
#: packages/email/template-components/template-document-invite.tsx
msgid "{teamName} has invited you to {0}<0/>\"{documentName}\""
msgstr "{teamName} hat dich eingeladen, {0}<0/>\"{documentName}\""
msgstr "{teamName} hat dich eingeladen, zu {0}<0/>\"{documentName}\""
#: packages/email/templates/document-invite.tsx
msgid "{teamName} has invited you to {action} {documentName}"
msgstr "{teamName} hat Sie eingeladen, {action} {documentName}"
msgstr "{teamName} hat Sie eingeladen, zu {action} {documentName}"
#: apps/remix/app/components/general/app-command-menu.tsx
msgid "{totalVisibleCount, plural, one {# item} other {# items}}"
@@ -9344,7 +9344,7 @@ msgstr "Erinnerung: {0}"
#: packages/lib/jobs/definitions/internal/process-signing-reminder.handler.ts
#: packages/lib/server-only/document/resend-document.ts
msgid "Reminder: {0} invited you to {recipientActionVerb} a document"
msgstr "Erinnerung: {0} hat dich eingeladen, ein Dokument {recipientActionVerb}"
msgstr "Erinnerung: {0} hat dich eingeladen, ein Dokument zu {recipientActionVerb}"
#. placeholder {0}: _(actionVerb).toLowerCase()
#: packages/email/template-components/template-document-reminder.tsx
@@ -0,0 +1,26 @@
export type OrganisationLimits = {
documentQuota: number | null;
emailQuota: number | null;
apiQuota: number | null;
teamCount: number;
memberCount: number;
recipientCount: number;
envelopeItemCount: number;
};
const ORGANISATION_LIMIT_KEYS = [
'documentQuota',
'emailQuota',
'apiQuota',
'teamCount',
'memberCount',
'recipientCount',
'envelopeItemCount',
] as const satisfies (keyof OrganisationLimits)[];
export const hasOrganisationLimitsChanged = (
previous: OrganisationLimits,
next: Partial<OrganisationLimits>,
): boolean => {
return ORGANISATION_LIMIT_KEYS.some((key) => next[key] !== undefined && next[key] !== previous[key]);
};
@@ -1,4 +1,6 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { jobs } from '@documenso/lib/jobs/client';
import { hasOrganisationLimitsChanged } from '@documenso/lib/universal/organisation-limit-changes';
import { prisma } from '@documenso/prisma';
import { adminProcedure } from '../trpc';
@@ -11,11 +13,12 @@ export const updateAdminOrganisationRoute = adminProcedure
.input(ZUpdateAdminOrganisationRequestSchema)
.output(ZUpdateAdminOrganisationResponseSchema)
.mutation(async ({ input, ctx }) => {
const { organisationId, data } = input;
const { organisationId, data, notifyOrganisation } = input;
ctx.logger.info({
input: {
organisationId,
notifyOrganisation,
},
});
@@ -34,6 +37,8 @@ export const updateAdminOrganisationRoute = adminProcedure
const { name, url, customerId, claims, originalSubscriptionClaimId } = data;
const isLimitsChanged = claims ? hasOrganisationLimitsChanged(organisation.organisationClaim, claims) : false;
await prisma.organisation.update({
where: {
id: organisationId,
@@ -54,4 +59,26 @@ export const updateAdminOrganisationRoute = adminProcedure
originalSubscriptionClaimId,
},
});
if (!notifyOrganisation) {
return { isNotificationSent: false };
}
if (!isLimitsChanged) {
ctx.logger.info({
msg: 'Skipping organisation limits updated email, no limits changed',
organisationId,
});
return { isNotificationSent: false };
}
await jobs.triggerJob({
name: 'send.organisation-limits-updated.email',
payload: {
organisationId,
},
});
return { isNotificationSent: true };
});
@@ -26,8 +26,11 @@ export const ZUpdateAdminOrganisationRequestSchema = z.object({
customerId: z.string().optional(),
originalSubscriptionClaimId: z.string().optional(),
}),
notifyOrganisation: z.boolean().optional(),
});
export const ZUpdateAdminOrganisationResponseSchema = z.void();
export const ZUpdateAdminOrganisationResponseSchema = z.object({
isNotificationSent: z.boolean(),
});
export type TUpdateAdminOrganisationRequest = z.infer<typeof ZUpdateAdminOrganisationRequestSchema>;