mirror of
https://github.com/documenso/documenso.git
synced 2025-11-12 07:43:16 +10:00
feat: add organisations (#1820)
This commit is contained in:
@ -1,36 +1,39 @@
|
||||
import type { Subscription } from '@prisma/client';
|
||||
import { SubscriptionStatus } from '@prisma/client';
|
||||
import type { Subscription } from '@documenso/prisma/generated/zod/modelSchema/SubscriptionSchema';
|
||||
|
||||
import { IS_BILLING_ENABLED } from '../constants/app';
|
||||
import { AppErrorCode } from '../errors/app-error';
|
||||
import { AppError } from '../errors/app-error';
|
||||
import type { StripeOrganisationCreateMetadata } from '../types/subscription';
|
||||
|
||||
export const generateStripeOrganisationCreateMetadata = (
|
||||
organisationName: string,
|
||||
userId: number,
|
||||
) => {
|
||||
const metadata: StripeOrganisationCreateMetadata = {
|
||||
organisationName,
|
||||
userId,
|
||||
};
|
||||
|
||||
return {
|
||||
organisationCreateData: JSON.stringify(metadata),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns true if there is a subscription that is active and is one of the provided price IDs.
|
||||
* Throws an error if billing is enabled and no subscription is found.
|
||||
*/
|
||||
export const subscriptionsContainsActivePlan = (
|
||||
subscriptions: Subscription[],
|
||||
priceIds: string[],
|
||||
allowPastDue?: boolean,
|
||||
) => {
|
||||
const allowedSubscriptionStatuses: SubscriptionStatus[] = [SubscriptionStatus.ACTIVE];
|
||||
export const validateIfSubscriptionIsRequired = (subscription?: Subscription | null) => {
|
||||
const isBillingEnabled = IS_BILLING_ENABLED();
|
||||
|
||||
if (allowPastDue) {
|
||||
allowedSubscriptionStatuses.push(SubscriptionStatus.PAST_DUE);
|
||||
if (!isBillingEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
return subscriptions.some(
|
||||
(subscription) =>
|
||||
allowedSubscriptionStatuses.includes(subscription.status) &&
|
||||
priceIds.includes(subscription.priceId),
|
||||
);
|
||||
};
|
||||
if (isBillingEnabled && !subscription) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Subscription not found',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if there is a subscription that is active and is one of the provided product IDs.
|
||||
*/
|
||||
export const subscriptionsContainsActiveProductId = (
|
||||
subscriptions: Subscription[],
|
||||
productId: string[],
|
||||
) => {
|
||||
return subscriptions.some(
|
||||
(subscription) =>
|
||||
subscription.status === SubscriptionStatus.ACTIVE && productId.includes(subscription.planId),
|
||||
);
|
||||
return subscription;
|
||||
};
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { I18n, MessageDescriptor } from '@lingui/core';
|
||||
import { i18n } from '@lingui/core';
|
||||
import type { MacroMessageDescriptor } from '@lingui/core/macro';
|
||||
|
||||
import type { I18nLocaleData, SupportedLanguageCodes } from '../constants/i18n';
|
||||
import { APP_I18N_OPTIONS } from '../constants/i18n';
|
||||
@ -84,3 +85,10 @@ export const extractLocaleData = ({ headers }: ExtractLocaleDataOptions): I18nLo
|
||||
export const parseMessageDescriptor = (_: I18n['_'], value: string | MessageDescriptor) => {
|
||||
return typeof value === 'string' ? value : _(value);
|
||||
};
|
||||
|
||||
export const parseMessageDescriptorMacro = (
|
||||
t: (descriptor: MacroMessageDescriptor) => string,
|
||||
value: string | MessageDescriptor,
|
||||
) => {
|
||||
return typeof value === 'string' ? value : t(value);
|
||||
};
|
||||
|
||||
14
packages/lib/utils/organisations-claims.ts
Normal file
14
packages/lib/utils/organisations-claims.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import type { SubscriptionClaim } from '@prisma/client';
|
||||
|
||||
export const generateDefaultSubscriptionClaim = (): Omit<
|
||||
SubscriptionClaim,
|
||||
'id' | 'organisation' | 'createdAt' | 'updatedAt' | 'originalSubscriptionClaimId'
|
||||
> => {
|
||||
return {
|
||||
name: '',
|
||||
teamCount: 1,
|
||||
memberCount: 1,
|
||||
locked: false,
|
||||
flags: {},
|
||||
};
|
||||
};
|
||||
128
packages/lib/utils/organisations.ts
Normal file
128
packages/lib/utils/organisations.ts
Normal file
@ -0,0 +1,128 @@
|
||||
import type { Organisation, OrganisationGlobalSettings, Prisma } from '@prisma/client';
|
||||
import {
|
||||
DocumentVisibility,
|
||||
type OrganisationGroup,
|
||||
type OrganisationMemberRole,
|
||||
} from '@prisma/client';
|
||||
|
||||
import type { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
|
||||
|
||||
import {
|
||||
LOWEST_ORGANISATION_ROLE,
|
||||
ORGANISATION_MEMBER_ROLE_HIERARCHY,
|
||||
ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP,
|
||||
} from '../constants/organisations';
|
||||
|
||||
export const isPersonalLayout = (organisations: Pick<Organisation, 'type'>[]) => {
|
||||
return organisations.length === 1 && organisations[0].type === 'PERSONAL';
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines whether a team member can execute a given action.
|
||||
*
|
||||
* @param action The action the user is trying to execute.
|
||||
* @param role The current role of the user.
|
||||
* @returns Whether the user can execute the action.
|
||||
*/
|
||||
export const canExecuteOrganisationAction = (
|
||||
action: keyof typeof ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP,
|
||||
role: keyof typeof ORGANISATION_MEMBER_ROLE_MAP,
|
||||
) => {
|
||||
return ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP[action].some((i) => i === role);
|
||||
};
|
||||
|
||||
/**
|
||||
* Compares the provided `currentUserRole` with the provided `roleToCheck` to determine
|
||||
* whether the `currentUserRole` has permission to modify the `roleToCheck`.
|
||||
*
|
||||
* @param currentUserRole Role of the current user
|
||||
* @param roleToCheck Role of another user to see if the current user can modify
|
||||
* @returns True if the current user can modify the other user, false otherwise
|
||||
*/
|
||||
export const isOrganisationRoleWithinUserHierarchy = (
|
||||
currentUserRole: keyof typeof ORGANISATION_MEMBER_ROLE_MAP,
|
||||
roleToCheck: keyof typeof ORGANISATION_MEMBER_ROLE_MAP,
|
||||
) => {
|
||||
return ORGANISATION_MEMBER_ROLE_HIERARCHY[currentUserRole].some((i) => i === roleToCheck);
|
||||
};
|
||||
|
||||
export const getHighestOrganisationRoleInGroup = (
|
||||
groups: Pick<OrganisationGroup, 'type' | 'organisationRole'>[],
|
||||
): OrganisationMemberRole => {
|
||||
let highestOrganisationRole: OrganisationMemberRole = LOWEST_ORGANISATION_ROLE;
|
||||
|
||||
groups.forEach((group) => {
|
||||
const currentRolePriority = ORGANISATION_MEMBER_ROLE_HIERARCHY[group.organisationRole].length;
|
||||
const highestOrganisationRolePriority =
|
||||
ORGANISATION_MEMBER_ROLE_HIERARCHY[highestOrganisationRole].length;
|
||||
|
||||
if (currentRolePriority > highestOrganisationRolePriority) {
|
||||
highestOrganisationRole = group.organisationRole;
|
||||
}
|
||||
});
|
||||
|
||||
return highestOrganisationRole;
|
||||
};
|
||||
|
||||
type BuildOrganisationWhereQueryOptions = {
|
||||
organisationId: string | undefined;
|
||||
userId: number;
|
||||
roles?: OrganisationMemberRole[];
|
||||
};
|
||||
|
||||
export const buildOrganisationWhereQuery = ({
|
||||
organisationId,
|
||||
userId,
|
||||
roles,
|
||||
}: BuildOrganisationWhereQueryOptions): Prisma.OrganisationWhereInput => {
|
||||
// Note: Not using inline ternary since typesafety breaks for some reason.
|
||||
if (!roles) {
|
||||
return {
|
||||
id: organisationId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: organisationId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
organisationGroupMembers: {
|
||||
some: {
|
||||
group: {
|
||||
organisationRole: {
|
||||
in: roles,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const generateDefaultOrganisationSettings = (): Omit<
|
||||
OrganisationGlobalSettings,
|
||||
'id' | 'organisation'
|
||||
> => {
|
||||
return {
|
||||
documentVisibility: DocumentVisibility.EVERYONE,
|
||||
documentLanguage: 'en',
|
||||
includeSenderDetails: true,
|
||||
includeSigningCertificate: true,
|
||||
|
||||
typedSignatureEnabled: true,
|
||||
uploadSignatureEnabled: true,
|
||||
drawSignatureEnabled: true,
|
||||
|
||||
brandingEnabled: false,
|
||||
brandingLogo: '',
|
||||
brandingUrl: '',
|
||||
brandingCompanyDetails: '',
|
||||
};
|
||||
};
|
||||
@ -1,13 +1,33 @@
|
||||
import type { TeamGlobalSettings } from '@prisma/client';
|
||||
import type { OrganisationGlobalSettings } from '@prisma/client';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
|
||||
|
||||
export const teamGlobalSettingsToBranding = (teamGlobalSettings: TeamGlobalSettings) => {
|
||||
export const teamGlobalSettingsToBranding = (
|
||||
settings: Omit<OrganisationGlobalSettings, 'id'>,
|
||||
teamId: number,
|
||||
hidePoweredBy: boolean,
|
||||
) => {
|
||||
return {
|
||||
...teamGlobalSettings,
|
||||
...settings,
|
||||
brandingLogo:
|
||||
teamGlobalSettings.brandingEnabled && teamGlobalSettings.brandingLogo
|
||||
? `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/team/${teamGlobalSettings.teamId}`
|
||||
settings.brandingEnabled && settings.brandingLogo
|
||||
? `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/team/${teamId}`
|
||||
: '',
|
||||
brandingHidePoweredBy: hidePoweredBy,
|
||||
};
|
||||
};
|
||||
|
||||
export const organisationGlobalSettingsToBranding = (
|
||||
settings: Omit<OrganisationGlobalSettings, 'id'>,
|
||||
organisationId: string,
|
||||
hidePoweredBy: boolean,
|
||||
) => {
|
||||
return {
|
||||
...settings,
|
||||
brandingLogo:
|
||||
settings.brandingEnabled && settings.brandingLogo
|
||||
? `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/organisation/${organisationId}`
|
||||
: '',
|
||||
brandingHidePoweredBy: hidePoweredBy,
|
||||
};
|
||||
};
|
||||
|
||||
@ -1,7 +1,24 @@
|
||||
import type { OrganisationGlobalSettings, Prisma, TeamGlobalSettings } from '@prisma/client';
|
||||
|
||||
import type { TeamGroup } from '@documenso/prisma/generated/types';
|
||||
import type { TeamMemberRole } from '@documenso/prisma/generated/types';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
|
||||
import { DocumentSignatureType } from '../constants/document';
|
||||
import type { TEAM_MEMBER_ROLE_MAP } from '../constants/teams';
|
||||
import { TEAM_MEMBER_ROLE_HIERARCHY, TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../constants/teams';
|
||||
import {
|
||||
LOWEST_TEAM_ROLE,
|
||||
TEAM_MEMBER_ROLE_HIERARCHY,
|
||||
TEAM_MEMBER_ROLE_PERMISSIONS_MAP,
|
||||
} from '../constants/teams';
|
||||
import type { TEAM_MEMBER_ROLE_MAP } from '../constants/teams-translations';
|
||||
|
||||
/**
|
||||
* Workaround for E2E tests to not import `msg`.
|
||||
*/
|
||||
export enum DocumentSignatureType {
|
||||
DRAW = 'draw',
|
||||
TYPE = 'type',
|
||||
UPLOAD = 'upload',
|
||||
}
|
||||
|
||||
export const formatTeamUrl = (teamUrl: string, baseUrl?: string) => {
|
||||
const formattedBaseUrl = (baseUrl ?? NEXT_PUBLIC_WEBAPP_URL()).replace(/https?:\/\//, '');
|
||||
@ -9,12 +26,12 @@ export const formatTeamUrl = (teamUrl: string, baseUrl?: string) => {
|
||||
return `${formattedBaseUrl}/t/${teamUrl}`;
|
||||
};
|
||||
|
||||
export const formatDocumentsPath = (teamUrl?: string) => {
|
||||
return teamUrl ? `/t/${teamUrl}/documents` : '/documents';
|
||||
export const formatDocumentsPath = (teamUrl: string) => {
|
||||
return `/t/${teamUrl}/documents`;
|
||||
};
|
||||
|
||||
export const formatTemplatesPath = (teamUrl?: string) => {
|
||||
return teamUrl ? `/t/${teamUrl}/templates` : '/templates';
|
||||
export const formatTemplatesPath = (teamUrl: string) => {
|
||||
return `/t/${teamUrl}/templates`;
|
||||
};
|
||||
|
||||
/**
|
||||
@ -46,11 +63,26 @@ export const isTeamRoleWithinUserHierarchy = (
|
||||
return TEAM_MEMBER_ROLE_HIERARCHY[currentUserRole].some((i) => i === roleToCheck);
|
||||
};
|
||||
|
||||
export const getHighestTeamRoleInGroup = (groups: TeamGroup[]): TeamMemberRole => {
|
||||
let highestTeamRole: TeamMemberRole = LOWEST_TEAM_ROLE;
|
||||
|
||||
groups.forEach((group) => {
|
||||
const currentRolePriority = TEAM_MEMBER_ROLE_HIERARCHY[group.teamRole].length;
|
||||
const highestTeamRolePriority = TEAM_MEMBER_ROLE_HIERARCHY[highestTeamRole].length;
|
||||
|
||||
if (currentRolePriority > highestTeamRolePriority) {
|
||||
highestTeamRole = group.teamRole;
|
||||
}
|
||||
});
|
||||
|
||||
return highestTeamRole;
|
||||
};
|
||||
|
||||
export const extractTeamSignatureSettings = (
|
||||
settings?: {
|
||||
typedSignatureEnabled: boolean;
|
||||
drawSignatureEnabled: boolean;
|
||||
uploadSignatureEnabled: boolean;
|
||||
typedSignatureEnabled: boolean | null;
|
||||
drawSignatureEnabled: boolean | null;
|
||||
uploadSignatureEnabled: boolean | null;
|
||||
} | null,
|
||||
) => {
|
||||
if (!settings) {
|
||||
@ -73,3 +105,103 @@ export const extractTeamSignatureSettings = (
|
||||
|
||||
return signatureTypes;
|
||||
};
|
||||
|
||||
type BuildTeamWhereQueryOptions = {
|
||||
teamId: number | undefined;
|
||||
userId: number;
|
||||
roles?: TeamMemberRole[];
|
||||
};
|
||||
|
||||
export const buildTeamWhereQuery = ({
|
||||
teamId,
|
||||
userId,
|
||||
roles,
|
||||
}: BuildTeamWhereQueryOptions): Prisma.TeamWhereUniqueInput => {
|
||||
// Note: Not using inline ternary since typesafety breaks for some reason.
|
||||
if (!roles) {
|
||||
return {
|
||||
id: teamId,
|
||||
teamGroups: {
|
||||
some: {
|
||||
organisationGroup: {
|
||||
organisationGroupMembers: {
|
||||
some: {
|
||||
organisationMember: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: teamId,
|
||||
teamGroups: {
|
||||
some: {
|
||||
organisationGroup: {
|
||||
organisationGroupMembers: {
|
||||
some: {
|
||||
organisationMember: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
teamRole: {
|
||||
in: roles,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Majority of these are null which lets us inherit from the organisation settings.
|
||||
*/
|
||||
export const generateDefaultTeamSettings = (): Omit<TeamGlobalSettings, 'id' | 'team'> => {
|
||||
return {
|
||||
documentVisibility: null,
|
||||
documentLanguage: null,
|
||||
includeSenderDetails: null,
|
||||
includeSigningCertificate: null,
|
||||
|
||||
typedSignatureEnabled: null,
|
||||
uploadSignatureEnabled: null,
|
||||
drawSignatureEnabled: null,
|
||||
|
||||
brandingEnabled: null,
|
||||
brandingLogo: null,
|
||||
brandingUrl: null,
|
||||
brandingCompanyDetails: null,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Derive the final settings for a team.
|
||||
*
|
||||
* @param organisationSettings The organisation settings to inherit values from
|
||||
* @param teamSettings The team settings which can override the organisation settings
|
||||
*/
|
||||
export const extractDerivedTeamSettings = (
|
||||
organisationSettings: Omit<OrganisationGlobalSettings, 'id'>,
|
||||
teamSettings: Omit<TeamGlobalSettings, 'id'>,
|
||||
): Omit<OrganisationGlobalSettings, 'id'> => {
|
||||
const derivedSettings: Omit<OrganisationGlobalSettings, 'id'> = {
|
||||
...organisationSettings,
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
for (const key of Object.keys(derivedSettings) as (keyof typeof derivedSettings)[]) {
|
||||
const teamValue = teamSettings[key];
|
||||
|
||||
if (teamValue !== null) {
|
||||
// @ts-expect-error Should work
|
||||
derivedSettings[key] = teamValue;
|
||||
}
|
||||
}
|
||||
|
||||
return derivedSettings;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user