mirror of
https://github.com/documenso/documenso.git
synced 2025-11-20 11:41:44 +10:00
fix: wip
This commit is contained in:
@ -0,0 +1,126 @@
|
||||
import { OrganisationGroupType, OrganisationMemberInviteStatus } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
|
||||
export type AcceptOrganisationInvitationOptions = {
|
||||
token: string;
|
||||
};
|
||||
|
||||
export const acceptOrganisationInvitation = async ({
|
||||
token,
|
||||
}: AcceptOrganisationInvitationOptions) => {
|
||||
const organisationMemberInvite = await prisma.organisationMemberInvite.findFirst({
|
||||
where: {
|
||||
token,
|
||||
status: {
|
||||
not: OrganisationMemberInviteStatus.DECLINED,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
organisation: {
|
||||
include: {
|
||||
subscriptions: true,
|
||||
groups: {
|
||||
include: {
|
||||
teamGroups: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!organisationMemberInvite) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND);
|
||||
}
|
||||
|
||||
if (organisationMemberInvite.status === OrganisationMemberInviteStatus.ACCEPTED) {
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: organisationMemberInvite.email,
|
||||
},
|
||||
});
|
||||
|
||||
// If no user exists for the invitation, accept the invitation and create the organisation
|
||||
// user when the user signs up.
|
||||
if (!user) {
|
||||
await prisma.organisationMemberInvite.update({
|
||||
where: {
|
||||
id: organisationMemberInvite.id,
|
||||
},
|
||||
data: {
|
||||
status: OrganisationMemberInviteStatus.ACCEPTED,
|
||||
},
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const { organisation } = organisationMemberInvite;
|
||||
|
||||
const organisationGroupToUse = organisation.groups.find(
|
||||
(group) =>
|
||||
group.type === OrganisationGroupType.INTERNAL_ORGANISATION &&
|
||||
group.organisationRole === organisationMemberInvite.organisationRole,
|
||||
);
|
||||
|
||||
if (!organisationGroupToUse) {
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||
message: 'Organisation group not found',
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
await tx.organisationMember.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
organisationId: organisation.id,
|
||||
organisationGroupMembers: {
|
||||
create: {
|
||||
groupId: organisationGroupToUse.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await tx.organisationMemberInvite.update({
|
||||
where: {
|
||||
id: organisationMemberInvite.id,
|
||||
},
|
||||
data: {
|
||||
status: OrganisationMemberInviteStatus.ACCEPTED,
|
||||
},
|
||||
});
|
||||
|
||||
// Todo: Orgs
|
||||
// if (IS_BILLING_ENABLED() && team.subscription) {
|
||||
// const numberOfSeats = await tx.teamMember.count({
|
||||
// where: {
|
||||
// teamId: organisationMemberInvite.teamId,
|
||||
// },
|
||||
// });
|
||||
|
||||
// await updateSubscriptionItemQuantity({
|
||||
// priceId: team.subscription.priceId,
|
||||
// subscriptionId: team.subscription.planId,
|
||||
// quantity: numberOfSeats,
|
||||
// });
|
||||
// }
|
||||
|
||||
// await jobs.triggerJob({
|
||||
// name: 'send.team-member-joined.email',
|
||||
// payload: {
|
||||
// teamId: teamMember.teamId,
|
||||
// memberId: teamMember.id,
|
||||
// },
|
||||
// });
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,47 @@
|
||||
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type CreateTeamBillingPortalOptions = {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
export const createTeamBillingPortal = async ({
|
||||
userId,
|
||||
teamId,
|
||||
}: CreateTeamBillingPortalOptions) => {
|
||||
if (!IS_BILLING_ENABLED()) {
|
||||
throw new Error('Billing is not enabled');
|
||||
}
|
||||
|
||||
const team = await prisma.team.findFirstOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
role: {
|
||||
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_BILLING'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
subscription: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!team.subscription) {
|
||||
throw new Error('Team has no subscription');
|
||||
}
|
||||
|
||||
if (!team.customerId) {
|
||||
throw new Error('Team has no customerId');
|
||||
}
|
||||
|
||||
return getPortalSession({
|
||||
customerId: team.customerId,
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,223 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import type { Organisation, OrganisationGlobalSettings, Prisma } from '@prisma/client';
|
||||
import { OrganisationMemberInviteStatus } from '@prisma/client';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { OrganisationInviteEmailTemplate } from '@documenso/email/templates/organisation-invite';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
|
||||
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { isOrganisationRoleWithinUserHierarchy } from '@documenso/lib/utils/organisations';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { TCreateOrganisationMemberInvitesRequestSchema } from '@documenso/trpc/server/organisation-router/create-organisation-member-invites.types';
|
||||
|
||||
import { getI18nInstance } from '../../client-only/providers/i18n-server';
|
||||
import {
|
||||
buildOrganisationWhereQuery,
|
||||
getHighestOrganisationRoleInGroup,
|
||||
} from '../../utils/organisations';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { organisationGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
|
||||
|
||||
export type CreateOrganisationMemberInvitesOptions = {
|
||||
userId: number;
|
||||
userName: string;
|
||||
organisationId: string;
|
||||
invitations: TCreateOrganisationMemberInvitesRequestSchema['invitations'];
|
||||
};
|
||||
|
||||
/**
|
||||
* Invite organisation members via email to join a organisation.
|
||||
*/
|
||||
export const createOrganisationMemberInvites = async ({
|
||||
userId,
|
||||
userName,
|
||||
organisationId,
|
||||
invitations,
|
||||
}: CreateOrganisationMemberInvitesOptions): Promise<void> => {
|
||||
const organisation = await prisma.organisation.findFirst({
|
||||
where: buildOrganisationWhereQuery(
|
||||
organisationId,
|
||||
userId,
|
||||
ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
|
||||
),
|
||||
include: {
|
||||
members: {
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
invites: true,
|
||||
organisationGlobalSettings: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!organisation) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND);
|
||||
}
|
||||
|
||||
const currentOrganisationMember = await prisma.organisationMember.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
organisationId,
|
||||
},
|
||||
include: {
|
||||
organisationGroupMembers: {
|
||||
include: {
|
||||
group: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!currentOrganisationMember) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
const currentOrganisationMemberRole = getHighestOrganisationRoleInGroup(
|
||||
currentOrganisationMember.organisationGroupMembers.map((member) => member.group),
|
||||
);
|
||||
|
||||
const organisationMemberEmails = organisation.members.map((member) => member.user.email);
|
||||
const organisationMemberInviteEmails = organisation.invites
|
||||
.filter((invite) => invite.status === OrganisationMemberInviteStatus.PENDING)
|
||||
.map((invite) => invite.email);
|
||||
|
||||
if (!currentOrganisationMember) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'User not part of organisation.',
|
||||
});
|
||||
}
|
||||
|
||||
const usersToInvite = invitations.filter((invitation) => {
|
||||
// Filter out users that are already members of the organisation.
|
||||
if (organisationMemberEmails.includes(invitation.email)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter out users that have already been invited to the organisation.
|
||||
if (organisationMemberInviteEmails.includes(invitation.email)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const unauthorizedRoleAccess = usersToInvite.some(
|
||||
({ organisationRole }) =>
|
||||
!isOrganisationRoleWithinUserHierarchy(currentOrganisationMemberRole, organisationRole),
|
||||
);
|
||||
|
||||
if (unauthorizedRoleAccess) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'User does not have permission to set high level roles',
|
||||
});
|
||||
}
|
||||
|
||||
// Todo: (orgs)
|
||||
const organisationMemberInvites: Prisma.OrganisationMemberInviteCreateManyInput[] =
|
||||
usersToInvite.map(({ email, organisationRole }) => ({
|
||||
email,
|
||||
organisationId,
|
||||
organisationRole,
|
||||
token: nanoid(32),
|
||||
}));
|
||||
|
||||
console.log({
|
||||
organisationMemberInvites,
|
||||
});
|
||||
|
||||
await prisma.organisationMemberInvite.createMany({
|
||||
data: organisationMemberInvites,
|
||||
});
|
||||
|
||||
const sendEmailResult = await Promise.allSettled(
|
||||
organisationMemberInvites.map(async ({ email, token }) =>
|
||||
sendOrganisationMemberInviteEmail({
|
||||
email,
|
||||
token,
|
||||
organisation,
|
||||
senderName: userName,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const sendEmailResultErrorList = sendEmailResult.filter(
|
||||
(result): result is PromiseRejectedResult => result.status === 'rejected',
|
||||
);
|
||||
|
||||
if (sendEmailResultErrorList.length > 0) {
|
||||
console.error(JSON.stringify(sendEmailResultErrorList));
|
||||
|
||||
throw new AppError('EmailDeliveryFailed', {
|
||||
message: 'Failed to send invite emails to one or more users.',
|
||||
userMessage: `Failed to send invites to ${sendEmailResultErrorList.length}/${organisationMemberInvites.length} users.`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
type SendOrganisationMemberInviteEmailOptions = {
|
||||
email: string;
|
||||
senderName: string;
|
||||
token: string;
|
||||
organisation: Organisation & {
|
||||
organisationGlobalSettings: OrganisationGlobalSettings;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Send an email to a user inviting them to join a organisation.
|
||||
*/
|
||||
export const sendOrganisationMemberInviteEmail = async ({
|
||||
email,
|
||||
senderName,
|
||||
token,
|
||||
organisation,
|
||||
}: SendOrganisationMemberInviteEmailOptions) => {
|
||||
const template = createElement(OrganisationInviteEmailTemplate, {
|
||||
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
|
||||
baseUrl: NEXT_PUBLIC_WEBAPP_URL(),
|
||||
senderName,
|
||||
token,
|
||||
organisationName: organisation.name,
|
||||
});
|
||||
|
||||
const branding = organisationGlobalSettingsToBranding(
|
||||
organisation.organisationGlobalSettings,
|
||||
organisation.id,
|
||||
);
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(template, {
|
||||
lang: organisation.organisationGlobalSettings.documentLanguage,
|
||||
branding,
|
||||
}),
|
||||
renderEmailWithI18N(template, {
|
||||
lang: organisation.organisationGlobalSettings.documentLanguage,
|
||||
branding,
|
||||
plainText: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const i18n = await getI18nInstance(organisation.organisationGlobalSettings.documentLanguage);
|
||||
|
||||
await mailer.sendMail({
|
||||
to: email,
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: i18n._(msg`You have been invited to join ${organisation.name} on Documenso`),
|
||||
html,
|
||||
text,
|
||||
});
|
||||
};
|
||||
114
packages/lib/server-only/organisation/create-organisation.ts
Normal file
114
packages/lib/server-only/organisation/create-organisation.ts
Normal file
@ -0,0 +1,114 @@
|
||||
import { OrganisationMemberRole } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { ORGANISATION_INTERNAL_GROUPS } from '../../constants/organisations';
|
||||
import { AppErrorCode } from '../../errors/app-error';
|
||||
import { AppError } from '../../errors/app-error';
|
||||
import { alphaid } from '../../universal/id';
|
||||
import { generateDefaultOrganisationSettings } from '../../utils/organisations';
|
||||
import { createTeam } from '../team/create-team';
|
||||
|
||||
type CreateOrganisationOptions = {
|
||||
userId: number;
|
||||
name: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export const createOrganisation = async ({ name, url, userId }: CreateOrganisationOptions) => {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const organisationSetting = await tx.organisationGlobalSettings.create({
|
||||
data: generateDefaultOrganisationSettings(),
|
||||
});
|
||||
|
||||
const organisation = await tx.organisation
|
||||
.create({
|
||||
data: {
|
||||
name,
|
||||
url, // Todo: orgs constraint this
|
||||
ownerUserId: userId,
|
||||
organisationGlobalSettingsId: organisationSetting.id,
|
||||
groups: {
|
||||
create: ORGANISATION_INTERNAL_GROUPS,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
groups: true,
|
||||
},
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.code === 'P2002') {
|
||||
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
|
||||
message: 'Organisation URL already exists',
|
||||
});
|
||||
}
|
||||
|
||||
throw err;
|
||||
});
|
||||
|
||||
const adminGroup = organisation.groups.find(
|
||||
(group) => group.organisationRole === OrganisationMemberRole.ADMIN,
|
||||
);
|
||||
|
||||
if (!adminGroup) {
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||
message: 'Admin group not found',
|
||||
});
|
||||
}
|
||||
|
||||
await tx.organisationMember.create({
|
||||
data: {
|
||||
userId,
|
||||
organisationId: organisation.id,
|
||||
organisationGroupMembers: {
|
||||
create: {
|
||||
groupId: adminGroup.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return organisation;
|
||||
});
|
||||
};
|
||||
|
||||
type CreatePersonalOrganisationOptions = {
|
||||
userId: number;
|
||||
orgUrl?: string;
|
||||
throwErrorOnOrganisationCreationFailure?: boolean;
|
||||
};
|
||||
|
||||
export const createPersonalOrganisation = async ({
|
||||
userId,
|
||||
orgUrl,
|
||||
throwErrorOnOrganisationCreationFailure = false,
|
||||
}: CreatePersonalOrganisationOptions) => {
|
||||
const organisation = await createOrganisation({
|
||||
name: 'Personal Organisation',
|
||||
userId,
|
||||
url: orgUrl || `org_${alphaid(8)}`,
|
||||
}).catch((err) => {
|
||||
console.error(err);
|
||||
|
||||
if (throwErrorOnOrganisationCreationFailure) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Todo: (orgs) Add logging.
|
||||
});
|
||||
|
||||
if (organisation) {
|
||||
await createTeam({
|
||||
userId,
|
||||
teamName: 'Personal Team',
|
||||
teamUrl: `personal_${alphaid(8)}`,
|
||||
organisationId: organisation.id,
|
||||
inheritMembers: true,
|
||||
}).catch((err) => {
|
||||
console.error(err);
|
||||
// Todo: (orgs) Add logging.
|
||||
});
|
||||
}
|
||||
|
||||
return organisation;
|
||||
};
|
||||
Reference in New Issue
Block a user