fix: send notification on limits change (#3414)

This commit is contained in:
Lucas Smith
2026-10-01 14:01:57 +10:00
committed by GitHub
parent 0f29ba6bb6
commit d448e3f8f6
9 changed files with 277 additions and 11 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
>;
@@ -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>;