feat: billing

This commit is contained in:
David Nguyen
2025-05-19 12:38:50 +10:00
parent 7abfc9e271
commit 2805478e0d
221 changed files with 8436 additions and 5847 deletions

View File

@ -1,13 +1,13 @@
import { JobClient } from './client/client';
import { SEND_CONFIRMATION_EMAIL_JOB_DEFINITION } from './definitions/emails/send-confirmation-email';
import { SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION } from './definitions/emails/send-document-cancelled-emails';
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_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION } from './definitions/emails/send-password-reset-success-email';
import { SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-recipient-signed-email';
import { SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION } from './definitions/emails/send-rejection-emails';
import { SEND_SIGNING_EMAIL_JOB_DEFINITION } from './definitions/emails/send-signing-email';
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';
@ -18,8 +18,8 @@ import { SEAL_DOCUMENT_JOB_DEFINITION } from './definitions/internal/seal-docume
export const jobsClient = new JobClient([
SEND_SIGNING_EMAIL_JOB_DEFINITION,
SEND_CONFIRMATION_EMAIL_JOB_DEFINITION,
SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION,
SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION,
SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION,
SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION,
SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION,
SEAL_DOCUMENT_JOB_DEFINITION,
SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION,

View File

@ -1,80 +1,85 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import { TeamMemberRole } from '@prisma/client';
import { mailer } from '@documenso/email/mailer';
import TeamJoinEmailTemplate from '@documenso/email/templates/team-join';
import OrganisationJoinEmailTemplate from '@documenso/email/templates/organisation-join';
import { prisma } from '@documenso/prisma';
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 { getTeamSettings } from '../../../server-only/team/get-team-settings';
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '../../../constants/organisations';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import { teamGlobalSettingsToBranding } from '../../../utils/team-global-settings-to-branding';
import { organisationGlobalSettingsToBranding } from '../../../utils/team-global-settings-to-branding';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSendTeamMemberJoinedEmailJobDefinition } from './send-team-member-joined-email';
import type { TSendOrganisationMemberJoinedEmailJobDefinition } from './send-organisation-member-joined-email';
export const run = async ({
payload,
io,
}: {
payload: TSendTeamMemberJoinedEmailJobDefinition;
payload: TSendOrganisationMemberJoinedEmailJobDefinition;
io: JobRunIO;
}) => {
const team = await prisma.team.findFirstOrThrow({
const organisation = await prisma.organisation.findFirstOrThrow({
where: {
id: payload.teamId,
id: payload.organisationId,
},
include: {
members: {
where: {
role: {
in: [TeamMemberRole.ADMIN, TeamMemberRole.MANAGER],
organisationGroupMembers: {
some: {
group: {
organisationRole: {
in: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
},
},
},
},
},
include: {
user: true,
},
},
organisationGlobalSettings: true,
},
});
const settings = await getTeamSettings({
userId: payload.userId,
teamId: payload.teamId,
});
const invitedMember = await prisma.teamMember.findFirstOrThrow({
const invitedMember = await prisma.organisationMember.findFirstOrThrow({
where: {
id: payload.memberId,
teamId: payload.teamId,
userId: payload.memberUserId,
organisationId: payload.organisationId,
},
include: {
user: true,
},
});
for (const member of team.members) {
for (const member of organisation.members) {
if (member.id === invitedMember.id) {
continue;
}
await io.runTask(
`send-team-member-joined-email--${invitedMember.id}_${member.id}`,
`send-organisation-member-joined-email--${invitedMember.id}_${member.id}`,
async () => {
const emailContent = createElement(TeamJoinEmailTemplate, {
const emailContent = createElement(OrganisationJoinEmailTemplate, {
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
baseUrl: NEXT_PUBLIC_WEBAPP_URL(),
memberName: invitedMember.user.name || '',
memberEmail: invitedMember.user.email,
teamName: team.name,
teamUrl: team.url,
organisationName: organisation.name,
organisationUrl: organisation.url,
});
const branding = teamGlobalSettingsToBranding(settings, team.id);
const lang = settings.documentLanguage;
const branding = organisationGlobalSettingsToBranding(
organisation.organisationGlobalSettings,
organisation.id,
);
const lang = organisation.organisationGlobalSettings.documentLanguage;
// !: Replace with the actual language of the recipient later
const [html, text] = await Promise.all([
@ -97,7 +102,7 @@ export const run = async ({
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: i18n._(msg`A new member has joined your team`),
subject: i18n._(msg`A new member has joined your organisation`),
html,
text,
});

View File

@ -0,0 +1,33 @@
import { z } from 'zod';
import type { JobDefinition } from '../../client/_internal/job';
const SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION_ID =
'send.organisation-member-joined.email';
const SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
organisationId: z.string(),
memberUserId: z.number(),
});
export type TSendOrganisationMemberJoinedEmailJobDefinition = z.infer<
typeof SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION_SCHEMA
>;
export const SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION = {
id: SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION_ID,
name: 'Send Organisation Member Joined Email',
version: '1.0.0',
trigger: {
name: SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION_ID,
schema: SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const handler = await import('./send-organisation-member-joined-email.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION_ID,
TSendOrganisationMemberJoinedEmailJobDefinition
>;

View File

@ -0,0 +1,107 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import { mailer } from '@documenso/email/mailer';
import OrganisationLeaveEmailTemplate from '@documenso/email/templates/organisation-leave';
import { prisma } from '@documenso/prisma';
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 { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '../../../constants/organisations';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import { organisationGlobalSettingsToBranding } from '../../../utils/team-global-settings-to-branding';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSendOrganisationMemberLeftEmailJobDefinition } from './send-organisation-member-left-email';
export const run = async ({
payload,
io,
}: {
payload: TSendOrganisationMemberLeftEmailJobDefinition;
io: JobRunIO;
}) => {
const organisation = await prisma.organisation.findFirstOrThrow({
where: {
id: payload.organisationId,
},
include: {
members: {
where: {
organisationGroupMembers: {
some: {
group: {
organisationRole: {
in: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
},
},
},
},
},
include: {
user: true,
},
},
organisationGlobalSettings: true,
},
});
const oldMember = await prisma.user.findFirstOrThrow({
where: {
id: payload.memberUserId,
},
});
for (const member of organisation.members) {
if (member.userId === oldMember.id) {
continue;
}
await io.runTask(
`send-organisation-member-left-email--${oldMember.id}_${member.id}`,
async () => {
const emailContent = createElement(OrganisationLeaveEmailTemplate, {
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
baseUrl: NEXT_PUBLIC_WEBAPP_URL(),
memberName: oldMember.name || '',
memberEmail: oldMember.email,
organisationName: organisation.name,
organisationUrl: organisation.url,
});
const branding = organisationGlobalSettingsToBranding(
organisation.organisationGlobalSettings,
organisation.id,
);
const lang = organisation.organisationGlobalSettings.documentLanguage;
const [html, text] = await Promise.all([
renderEmailWithI18N(emailContent, {
lang,
branding,
}),
renderEmailWithI18N(emailContent, {
lang,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(lang);
await mailer.sendMail({
to: member.user.email,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: i18n._(msg`A member has left your organisation`),
html,
text,
});
},
);
}
};

View File

@ -0,0 +1,32 @@
import { z } from 'zod';
import type { JobDefinition } from '../../client/_internal/job';
const SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION_ID = 'send.organisation-member-left.email';
const SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
organisationId: z.string(),
memberUserId: z.number(),
});
export type TSendOrganisationMemberLeftEmailJobDefinition = z.infer<
typeof SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION_SCHEMA
>;
export const SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION = {
id: SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION_ID,
name: 'Send Organisation Member Left Email',
version: '1.0.0',
trigger: {
name: SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION_ID,
schema: SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const handler = await import('./send-organisation-member-left-email.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION_ID,
TSendOrganisationMemberLeftEmailJobDefinition
>;

View File

@ -1,4 +1,3 @@
import { DocumentVisibility } from '@prisma/client';
import { z } from 'zod';
import type { JobDefinition } from '../../client/_internal/job';
@ -9,23 +8,24 @@ const SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
team: z.object({
name: z.string(),
url: z.string(),
teamGlobalSettings: z
.object({
documentVisibility: z.nativeEnum(DocumentVisibility),
documentLanguage: z.string(),
includeSenderDetails: z.boolean(),
includeSigningCertificate: z.boolean(),
brandingEnabled: z.boolean(),
brandingLogo: z.string(),
brandingUrl: z.string(),
brandingCompanyDetails: z.string(),
brandingHidePoweredBy: z.boolean(),
teamId: z.number(),
typedSignatureEnabled: z.boolean(),
uploadSignatureEnabled: z.boolean(),
drawSignatureEnabled: z.boolean(),
})
.nullish(),
// This is never passed along for some reason so commenting it out.
// teamGlobalSettings: z
// .object({
// documentVisibility: z.nativeEnum(DocumentVisibility),
// documentLanguage: z.string(),
// includeSenderDetails: z.boolean(),
// includeSigningCertificate: z.boolean(),
// brandingEnabled: z.boolean(),
// brandingLogo: z.string(),
// brandingUrl: z.string(),
// brandingCompanyDetails: z.string(),
// brandingHidePoweredBy: z.boolean(),
// teamId: z.number(),
// typedSignatureEnabled: z.boolean(),
// uploadSignatureEnabled: z.boolean(),
// drawSignatureEnabled: z.boolean(),
// })
// .nullish(),
}),
members: z.array(
z.object({

View File

@ -1,32 +0,0 @@
import { z } from 'zod';
import type { JobDefinition } from '../../client/_internal/job';
const SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION_ID = 'send.team-member-joined.email';
const SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
teamId: z.number(),
memberId: z.number(),
});
export type TSendTeamMemberJoinedEmailJobDefinition = z.infer<
typeof SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION_SCHEMA
>;
export const SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION = {
id: SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION_ID,
name: 'Send Team Member Joined Email',
version: '1.0.0',
trigger: {
name: SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION_ID,
schema: SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const handler = await import('./send-team-member-joined-email.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION_ID,
TSendTeamMemberJoinedEmailJobDefinition
>;

View File

@ -1,94 +0,0 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import { TeamMemberRole } from '@prisma/client';
import { mailer } from '@documenso/email/mailer';
import TeamJoinEmailTemplate from '@documenso/email/templates/team-join';
import { prisma } from '@documenso/prisma';
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 { getTeamSettings } from '../../../server-only/team/get-team-settings';
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 { TSendTeamMemberLeftEmailJobDefinition } from './send-team-member-left-email';
export const run = async ({
payload,
io,
}: {
payload: TSendTeamMemberLeftEmailJobDefinition;
io: JobRunIO;
}) => {
const team = await prisma.team.findFirstOrThrow({
where: {
id: payload.teamId,
},
include: {
members: {
where: {
role: {
in: [TeamMemberRole.ADMIN, TeamMemberRole.MANAGER],
},
},
include: {
user: true,
},
},
},
});
const settings = await getTeamSettings({
teamId: payload.teamId,
});
const oldMember = await prisma.user.findFirstOrThrow({
where: {
id: payload.memberUserId,
},
});
for (const member of team.members) {
await io.runTask(`send-team-member-left-email--${oldMember.id}_${member.id}`, async () => {
const emailContent = createElement(TeamJoinEmailTemplate, {
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
baseUrl: NEXT_PUBLIC_WEBAPP_URL(),
memberName: oldMember.name || '',
memberEmail: oldMember.email,
teamName: team.name,
teamUrl: team.url,
});
const branding = teamGlobalSettingsToBranding(settings, team.id);
const lang = settings.documentLanguage;
const [html, text] = await Promise.all([
renderEmailWithI18N(emailContent, {
lang,
branding,
}),
renderEmailWithI18N(emailContent, {
lang,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(lang);
await mailer.sendMail({
to: member.user.email,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: i18n._(msg`A team member has left ${team.name}`),
html,
text,
});
});
}
};

View File

@ -1,32 +0,0 @@
import { z } from 'zod';
import type { JobDefinition } from '../../client/_internal/job';
const SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION_ID = 'send.team-member-left.email';
const SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
teamId: z.number(),
memberUserId: z.number(),
});
export type TSendTeamMemberLeftEmailJobDefinition = z.infer<
typeof SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION_SCHEMA
>;
export const SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION = {
id: SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION_ID,
name: 'Send Team Member Left Email',
version: '1.0.0',
trigger: {
name: SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION_ID,
schema: SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION_SCHEMA,
},
handler: async ({ payload, io }) => {
const handler = await import('./send-team-member-left-email.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION_ID,
TSendTeamMemberLeftEmailJobDefinition
>;