feat: add organisations (#1820)

This commit is contained in:
David Nguyen
2025-06-10 11:49:52 +10:00
committed by GitHub
parent 0b37f19641
commit e6dc237ad2
631 changed files with 37616 additions and 25695 deletions

View File

@ -0,0 +1,18 @@
import { acceptOrganisationInvitation } from '@documenso/lib/server-only/organisation/accept-organisation-invitation';
import { maybeAuthenticatedProcedure } from '../trpc';
import {
ZAcceptOrganisationMemberInviteRequestSchema,
ZAcceptOrganisationMemberInviteResponseSchema,
} from './accept-organisation-member-invite.types';
export const acceptOrganisationMemberInviteRoute = maybeAuthenticatedProcedure
.input(ZAcceptOrganisationMemberInviteRequestSchema)
.output(ZAcceptOrganisationMemberInviteResponseSchema)
.mutation(async ({ input }) => {
const { token } = input;
return await acceptOrganisationInvitation({
token,
});
});

View File

@ -0,0 +1,11 @@
import { z } from 'zod';
export const ZAcceptOrganisationMemberInviteRequestSchema = z.object({
token: z.string(),
});
export const ZAcceptOrganisationMemberInviteResponseSchema = z.void();
export type TAcceptOrganisationMemberInviteResponse = z.infer<
typeof ZAcceptOrganisationMemberInviteResponseSchema
>;

View File

@ -0,0 +1,91 @@
import { OrganisationGroupType } from '@prisma/client';
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getMemberOrganisationRole } from '@documenso/lib/server-only/team/get-member-roles';
import { generateDatabaseId } from '@documenso/lib/universal/id';
import {
buildOrganisationWhereQuery,
isOrganisationRoleWithinUserHierarchy,
} from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import {
ZCreateOrganisationGroupRequestSchema,
ZCreateOrganisationGroupResponseSchema,
} from './create-organisation-group.types';
export const createOrganisationGroupRoute = authenticatedProcedure
// .meta(createOrganisationGroupMeta)
.input(ZCreateOrganisationGroupRequestSchema)
.output(ZCreateOrganisationGroupResponseSchema)
.mutation(async ({ input, ctx }) => {
const { organisationId, organisationRole, name, memberIds } = input;
const { user } = ctx;
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({
organisationId,
userId: user.id,
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
}),
include: {
groups: true,
members: {
include: {
user: true,
},
},
},
});
if (!organisation) {
throw new AppError(AppErrorCode.UNAUTHORIZED);
}
const currentUserOrganisationRole = await getMemberOrganisationRole({
organisationId,
reference: {
type: 'User',
id: user.id,
},
});
if (!isOrganisationRoleWithinUserHierarchy(currentUserOrganisationRole, organisationRole)) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You are not allowed to create this organisation group',
});
}
// Validate that members exist in the organisation.
memberIds.forEach((memberId) => {
const member = organisation.members.find(({ id }) => id === memberId);
if (!member) {
throw new AppError(AppErrorCode.NOT_FOUND);
}
});
await prisma.$transaction(async (tx) => {
const group = await tx.organisationGroup.create({
data: {
id: generateDatabaseId('org_group'),
organisationId,
name,
type: OrganisationGroupType.CUSTOM,
organisationRole,
},
});
await tx.organisationGroupMember.createMany({
data: memberIds.map((memberId) => ({
id: generateDatabaseId('group_member'),
organisationMemberId: memberId,
groupId: group.id,
})),
});
return group;
});
});

View File

@ -0,0 +1,21 @@
import { OrganisationMemberRole } from '@prisma/client';
import { z } from 'zod';
// export const createOrganisationGroupMeta: TrpcOpenApiMeta = {
// openapi: {
// method: 'POST',
// path: '/organisation/{teamId}/groups',
// summary: 'Create organisation group',
// description: 'Create a new group for a organisation',
// tags: ['Organisation'],
// },
// };
export const ZCreateOrganisationGroupRequestSchema = z.object({
organisationId: z.string(),
organisationRole: z.nativeEnum(OrganisationMemberRole),
name: z.string().max(100),
memberIds: z.array(z.string()),
});
export const ZCreateOrganisationGroupResponseSchema = z.void();

View File

@ -0,0 +1,23 @@
import { createOrganisationMemberInvites } from '@documenso/lib/server-only/organisation/create-organisation-member-invites';
import { authenticatedProcedure } from '../trpc';
import {
ZCreateOrganisationMemberInvitesRequestSchema,
ZCreateOrganisationMemberInvitesResponseSchema,
} from './create-organisation-member-invites.types';
export const createOrganisationMemberInvitesRoute = authenticatedProcedure
.input(ZCreateOrganisationMemberInvitesRequestSchema)
.output(ZCreateOrganisationMemberInvitesResponseSchema)
.mutation(async ({ ctx, input }) => {
const { organisationId, invitations } = input;
const userId = ctx.user.id;
const userName = ctx.user.name || '';
await createOrganisationMemberInvites({
userId,
userName,
organisationId,
invitations,
});
});

View File

@ -0,0 +1,42 @@
import { OrganisationMemberRole } from '@prisma/client';
import { z } from 'zod';
// export const createOrganisationMemberInvitesMeta: TrpcOpenApiMeta = {
// openapi: {
// method: 'POST',
// path: '/organisation/member/create',
// summary: 'Invite organisation members',
// description: 'Invite a users to be part of your organisation',
// tags: ['Organisation'],
// },
// };
export const ZCreateOrganisationMemberInvitesRequestSchema = z.object({
organisationId: z.string().describe('The organisation to invite the user to'),
invitations: z
.array(
z.object({
email: z.string().trim().email().toLowerCase(),
organisationRole: z.nativeEnum(OrganisationMemberRole),
}),
)
.min(1)
.refine(
(invitations) => {
const emails = invitations
.filter((invitation) => invitation.email !== undefined)
.map((invitation) => invitation.email);
return new Set(emails).size === emails.length;
},
{
message: 'Emails must be unique, no duplicate values allowed',
},
),
});
export const ZCreateOrganisationMemberInvitesResponseSchema = z.void();
export type TCreateOrganisationMemberInvitesRequestSchema = z.infer<
typeof ZCreateOrganisationMemberInvitesRequestSchema
>;

View File

@ -0,0 +1,79 @@
import { OrganisationType } from '@prisma/client';
import { createCheckoutSession } from '@documenso/ee/server-only/stripe/create-checkout-session';
import { createCustomer } from '@documenso/ee/server-only/stripe/create-customer';
import { IS_BILLING_ENABLED, NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { createOrganisation } from '@documenso/lib/server-only/organisation/create-organisation';
import { INTERNAL_CLAIM_ID, internalClaims } from '@documenso/lib/types/subscription';
import { generateStripeOrganisationCreateMetadata } from '@documenso/lib/utils/billing';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import {
ZCreateOrganisationRequestSchema,
ZCreateOrganisationResponseSchema,
} from './create-organisation.types';
export const createOrganisationRoute = authenticatedProcedure
// .meta(createOrganisationMeta)
.input(ZCreateOrganisationRequestSchema)
.output(ZCreateOrganisationResponseSchema)
.mutation(async ({ input, ctx }) => {
const { name, priceId } = input;
const { user } = ctx;
// Check if user can create a free organiastion.
if (IS_BILLING_ENABLED() && !priceId) {
const userOrganisations = await prisma.organisation.findMany({
where: {
ownerUserId: user.id,
subscription: {
is: null,
},
},
});
if (userOrganisations.length >= 1) {
throw new AppError(AppErrorCode.LIMIT_EXCEEDED, {
message: 'You have reached the maximum number of free organisations.',
});
}
}
// Create checkout session for payment.
if (IS_BILLING_ENABLED() && priceId) {
const customer = await createCustomer({
email: user.email,
name: user.name || user.email,
});
const checkoutUrl = await createCheckoutSession({
priceId,
customerId: customer.id,
returnUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/settings/organisations`,
subscriptionMetadata: generateStripeOrganisationCreateMetadata(name, user.id),
});
return {
paymentRequired: true,
checkoutUrl,
};
}
// Free organisations should be Personal by default.
const organisationType = IS_BILLING_ENABLED()
? OrganisationType.PERSONAL
: OrganisationType.ORGANISATION;
await createOrganisation({
userId: user.id,
name,
type: organisationType,
claim: internalClaims[INTERNAL_CLAIM_ID.FREE],
});
return {
paymentRequired: false,
};
});

View File

@ -0,0 +1,33 @@
import { z } from 'zod';
// export const createOrganisationMeta: TrpcOpenApiMeta = {
// openapi: {
// method: 'POST',
// path: '/organisation',
// summary: 'Create organisation',
// description: 'Create an organisation',
// tags: ['Organisation'],
// },
// };
export const ZOrganisationNameSchema = z
.string()
.min(3, { message: 'Minimum 3 characters' })
.max(50, { message: 'Maximum 50 characters' });
export const ZCreateOrganisationRequestSchema = z.object({
name: ZOrganisationNameSchema,
priceId: z.string().optional(),
});
export const ZCreateOrganisationResponseSchema = z.union([
z.object({
paymentRequired: z.literal(false),
}),
z.object({
paymentRequired: z.literal(true),
checkoutUrl: z.string(),
}),
]);
export type TCreateOrganisationResponse = z.infer<typeof ZCreateOrganisationResponseSchema>;

View File

@ -0,0 +1,38 @@
import { OrganisationMemberInviteStatus } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { maybeAuthenticatedProcedure } from '../trpc';
import {
ZDeclineOrganisationMemberInviteRequestSchema,
ZDeclineOrganisationMemberInviteResponseSchema,
} from './decline-organisation-member-invite.types';
export const declineOrganisationMemberInviteRoute = maybeAuthenticatedProcedure
.input(ZDeclineOrganisationMemberInviteRequestSchema)
.output(ZDeclineOrganisationMemberInviteResponseSchema)
.mutation(async ({ input }) => {
const { token } = input;
const organisationMemberInvite = await prisma.organisationMemberInvite.findFirst({
where: {
token,
},
});
if (!organisationMemberInvite) {
throw new AppError(AppErrorCode.NOT_FOUND);
}
await prisma.organisationMemberInvite.update({
where: {
id: organisationMemberInvite.id,
},
data: {
status: OrganisationMemberInviteStatus.DECLINED,
},
});
// TODO: notify the team owner
});

View File

@ -0,0 +1,11 @@
import { z } from 'zod';
export const ZDeclineOrganisationMemberInviteRequestSchema = z.object({
token: z.string(),
});
export const ZDeclineOrganisationMemberInviteResponseSchema = z.void();
export type TDeclineOrganisationMemberInviteResponse = z.infer<
typeof ZDeclineOrganisationMemberInviteResponseSchema
>;

View File

@ -0,0 +1,62 @@
import { OrganisationGroupType } from '@prisma/client';
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import {
ZDeleteOrganisationGroupRequestSchema,
ZDeleteOrganisationGroupResponseSchema,
} from './delete-organisation-group.types';
export const deleteOrganisationGroupRoute = authenticatedProcedure
// .meta(deleteOrganisationGroupMeta)
.input(ZDeleteOrganisationGroupRequestSchema)
.output(ZDeleteOrganisationGroupResponseSchema)
.mutation(async ({ input, ctx }) => {
const { groupId, organisationId } = input;
const { user } = ctx;
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({
organisationId,
userId: user.id,
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
}),
});
if (!organisation) {
throw new AppError(AppErrorCode.UNAUTHORIZED);
}
const group = await prisma.organisationGroup.findFirst({
where: {
id: groupId,
organisationId,
},
});
if (!group) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Organisation group not found',
});
}
if (
group.type === OrganisationGroupType.INTERNAL_ORGANISATION ||
group.type === OrganisationGroupType.INTERNAL_TEAM
) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You are not allowed to delete internal groups',
});
}
await prisma.organisationGroup.delete({
where: {
id: groupId,
organisationId: organisation.id,
},
});
});

View File

@ -0,0 +1,18 @@
import { z } from 'zod';
// export const deleteOrganisationGroupMeta: TrpcOpenApiMeta = {
// openapi: {
// method: 'POST',
// path: '/organisation/groups/{id}/delete',
// summary: 'Delete organisation group',
// description: 'Delete an existing group for a organisation',
// tags: ['Organisation'],
// },
// };
export const ZDeleteOrganisationGroupRequestSchema = z.object({
organisationId: z.string(),
groupId: z.string(),
});
export const ZDeleteOrganisationGroupResponseSchema = z.void();

View File

@ -0,0 +1,72 @@
import { syncMemberCountWithStripeSeatPlan } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { validateIfSubscriptionIsRequired } from '@documenso/lib/utils/billing';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import {
ZDeleteOrganisationMemberInvitesRequestSchema,
ZDeleteOrganisationMemberInvitesResponseSchema,
} from './delete-organisation-member-invites.types';
export const deleteOrganisationMemberInvitesRoute = authenticatedProcedure
// .meta(deleteOrganisationMemberInvitesMeta)
.input(ZDeleteOrganisationMemberInvitesRequestSchema)
.output(ZDeleteOrganisationMemberInvitesResponseSchema)
.mutation(async ({ ctx, input }) => {
const { organisationId, invitationIds } = input;
const userId = ctx.user.id;
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({
organisationId,
userId,
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
}),
include: {
organisationClaim: true,
subscription: true,
members: {
select: {
id: true,
},
},
invites: {
select: {
id: true,
},
},
},
});
if (!organisation) {
throw new AppError(AppErrorCode.NOT_FOUND);
}
const { organisationClaim } = organisation;
const subscription = validateIfSubscriptionIsRequired(organisation.subscription);
const numberOfCurrentMembers = organisation.members.length;
const numberOfCurrentInvites = organisation.invites.length;
const totalMemberCountWithInvites = numberOfCurrentMembers + numberOfCurrentInvites - 1;
if (subscription) {
await syncMemberCountWithStripeSeatPlan(
subscription,
organisationClaim,
totalMemberCountWithInvites,
);
}
await prisma.organisationMemberInvite.deleteMany({
where: {
id: {
in: invitationIds,
},
organisationId,
},
});
});

View File

@ -0,0 +1,20 @@
import { z } from 'zod';
// export const deleteOrganisationMemberInvitesMeta: TrpcOpenApiMeta = {
// openapi: {
// method: 'POST',
// path: '/organisation/member/delete-many',
// summary: 'Delete organisation member invites',
// description: 'Delete organisation member invites',
// tags: ['Organisation'],
// },
// };
export const ZDeleteOrganisationMemberInvitesRequestSchema = z.object({
organisationId: z.string(),
invitationIds: z.array(z.string()).refine((items) => new Set(items).size === items.length, {
message: 'Invitation IDs must be unique, no duplicate values allowed',
}),
});
export const ZDeleteOrganisationMemberInvitesResponseSchema = z.void();

View File

@ -0,0 +1,21 @@
import { authenticatedProcedure } from '../trpc';
import {
ZDeleteOrganisationMemberRequestSchema,
ZDeleteOrganisationMemberResponseSchema,
} from './delete-organisation-member.types';
import { deleteOrganisationMembers } from './delete-organisation-members';
export const deleteOrganisationMemberRoute = authenticatedProcedure
// .meta(deleteOrganisationMemberMeta)
.input(ZDeleteOrganisationMemberRequestSchema)
.output(ZDeleteOrganisationMemberResponseSchema)
.mutation(async ({ ctx, input }) => {
const { organisationId, organisationMemberId } = input;
const userId = ctx.user.id;
await deleteOrganisationMembers({
userId,
organisationId,
organisationMemberIds: [organisationMemberId],
});
});

View File

@ -0,0 +1,18 @@
import { z } from 'zod';
// export const deleteOrganisationMemberMeta: TrpcOpenApiMeta = {
// openapi: {
// method: 'POST',
// path: '/organisation/member/delete',
// summary: 'Delete organisation member',
// description: 'Delete organisation member',
// tags: ['Organisation'],
// },
// };
export const ZDeleteOrganisationMemberRequestSchema = z.object({
organisationId: z.string(),
organisationMemberId: z.string(),
});
export const ZDeleteOrganisationMemberResponseSchema = z.void();

View File

@ -0,0 +1,105 @@
import { syncMemberCountWithStripeSeatPlan } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { jobs } from '@documenso/lib/jobs/client';
import { validateIfSubscriptionIsRequired } from '@documenso/lib/utils/billing';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
import { OrganisationMemberInviteStatus } from '@documenso/prisma/client';
import { authenticatedProcedure } from '../trpc';
import {
ZDeleteOrganisationMembersRequestSchema,
ZDeleteOrganisationMembersResponseSchema,
} from './delete-organisation-members.types';
export const deleteOrganisationMembersRoute = authenticatedProcedure
// .meta(deleteOrganisationMembersMeta)
.input(ZDeleteOrganisationMembersRequestSchema)
.output(ZDeleteOrganisationMembersResponseSchema)
.mutation(async ({ ctx, input }) => {
const { organisationId, organisationMemberIds } = input;
const userId = ctx.user.id;
await deleteOrganisationMembers({
userId,
organisationId,
organisationMemberIds,
});
});
type DeleteOrganisationMembersProps = {
userId: number;
organisationId: string;
organisationMemberIds: string[];
};
export const deleteOrganisationMembers = async ({
userId,
organisationId,
organisationMemberIds,
}: DeleteOrganisationMembersProps) => {
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({
organisationId,
userId,
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
}),
include: {
subscription: true,
organisationClaim: true,
members: {
select: {
id: true,
userId: true,
},
},
invites: {
where: {
status: OrganisationMemberInviteStatus.PENDING,
},
select: {
id: true,
},
},
},
});
if (!organisation) {
throw new AppError(AppErrorCode.UNAUTHORIZED);
}
const { organisationClaim } = organisation;
const membersToDelete = organisation.members.filter((member) =>
organisationMemberIds.includes(member.id),
);
const subscription = validateIfSubscriptionIsRequired(organisation.subscription);
const inviteCount = organisation.invites.length;
const newMemberCount = organisation.members.length + inviteCount - membersToDelete.length;
if (subscription) {
await syncMemberCountWithStripeSeatPlan(subscription, organisationClaim, newMemberCount);
}
await prisma.$transaction(async (tx) => {
await tx.organisationMember.deleteMany({
where: {
id: {
in: organisationMemberIds,
},
organisationId,
},
});
await jobs.triggerJob({
name: 'send.organisation-member-left.email',
payload: {
organisationId,
memberUserId: userId,
},
});
});
};

View File

@ -0,0 +1,22 @@
import { z } from 'zod';
// export const deleteOrganisationMembersMeta: TrpcOpenApiMeta = {
// openapi: {
// method: 'POST',
// path: '/organisation/member/delete-many',
// summary: 'Delete organisation members',
// description: 'Delete organisation members',
// tags: ['Organisation'],
// },
// };
export const ZDeleteOrganisationMembersRequestSchema = z.object({
organisationId: z.string(),
organisationMemberIds: z
.array(z.string())
.refine((items) => new Set(items).size === items.length, {
message: 'Organisation member ids must be unique, no duplicate values allowed',
}),
});
export const ZDeleteOrganisationMembersResponseSchema = z.void();

View File

@ -0,0 +1,39 @@
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import {
ZDeleteOrganisationRequestSchema,
ZDeleteOrganisationResponseSchema,
} from './delete-organisation.types';
export const deleteOrganisationRoute = authenticatedProcedure
// .meta(deleteOrganisationMeta)
.input(ZDeleteOrganisationRequestSchema)
.output(ZDeleteOrganisationResponseSchema)
.mutation(async ({ input, ctx }) => {
const { organisationId } = input;
const { user } = ctx;
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({
organisationId,
userId: user.id,
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['DELETE_ORGANISATION'],
}),
});
if (!organisation) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You are not authorized to delete this organisation',
});
}
await prisma.organisation.delete({
where: {
id: organisation.id,
},
});
});

View File

@ -0,0 +1,17 @@
import { z } from 'zod';
// export const deleteOrganisationMeta: TrpcOpenApiMeta = {
// openapi: {
// method: 'DELETE',
// path: '/organisation/{teamId}',
// summary: 'Delete organisation',
// description: 'Delete an existing organisation',
// tags: ['Organisation'],
// },
// };
export const ZDeleteOrganisationRequestSchema = z.object({
organisationId: z.string(),
});
export const ZDeleteOrganisationResponseSchema = z.void();

View File

@ -0,0 +1,164 @@
import type { OrganisationGroupType, OrganisationMemberRole } from '@prisma/client';
import { Prisma } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { FindResultResponse } from '@documenso/lib/types/search-params';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import {
ZFindOrganisationGroupsRequestSchema,
ZFindOrganisationGroupsResponseSchema,
} from './find-organisation-groups.types';
export const findOrganisationGroupsRoute = authenticatedProcedure
// .meta(findOrganisationGroupsMeta)
.input(ZFindOrganisationGroupsRequestSchema)
.output(ZFindOrganisationGroupsResponseSchema)
.query(async ({ input, ctx }) => {
const { organisationId, types, query, page, perPage, organisationGroupId, organisationRoles } =
input;
const { user } = ctx;
return await findOrganisationGroups({
userId: user.id,
organisationId,
organisationGroupId,
organisationRoles,
types,
query,
page,
perPage,
});
});
type FindOrganisationGroupsOptions = {
userId: number;
organisationId: string;
organisationGroupId?: string;
organisationRoles?: OrganisationMemberRole[];
types?: OrganisationGroupType[];
query?: string;
page?: number;
perPage?: number;
};
export const findOrganisationGroups = async ({
userId,
organisationId,
organisationGroupId,
organisationRoles = [],
types = [],
query,
page = 1,
perPage = 10,
}: FindOrganisationGroupsOptions) => {
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({ organisationId, userId }),
});
if (!organisation) {
throw new AppError(AppErrorCode.NOT_FOUND);
}
const whereClause: Prisma.OrganisationGroupWhereInput = {
organisationId: organisation.id,
type:
types.length > 0
? {
in: types,
}
: undefined,
organisationRole:
organisationRoles.length > 0
? {
in: organisationRoles,
}
: undefined,
id: organisationGroupId,
};
if (query) {
whereClause.name = {
contains: query,
mode: Prisma.QueryMode.insensitive,
};
}
const [data, count] = await Promise.all([
prisma.organisationGroup.findMany({
where: whereClause,
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
orderBy: {
name: 'desc',
},
select: {
id: true,
name: true,
type: true,
organisationId: true,
organisationRole: true,
teamGroups: {
select: {
id: true,
teamId: true,
teamRole: true,
team: {
select: {
id: true,
name: true,
},
},
},
},
organisationGroupMembers: {
select: {
organisationMember: {
select: {
id: true,
user: {
select: {
id: true,
email: true,
name: true,
avatarImageId: true,
},
},
},
},
},
},
},
}),
prisma.organisationGroup.count({
where: whereClause,
}),
]);
const mappedData = data.map((group) => ({
...group,
teams: group.teamGroups.map((teamGroup) => ({
id: teamGroup.team.id,
name: teamGroup.team.name,
teamGroupId: teamGroup.id,
teamRole: teamGroup.teamRole,
})),
members: group.organisationGroupMembers.map(({ organisationMember }) => ({
id: organisationMember.id,
userId: organisationMember.user.id,
name: organisationMember.user.name || '',
email: organisationMember.user.email,
avatarImageId: organisationMember.user.avatarImageId,
})),
}));
return {
data: mappedData,
count,
currentPage: Math.max(page, 1),
perPage,
totalPages: Math.ceil(count / perPage),
} satisfies FindResultResponse<typeof mappedData>;
};

View File

@ -0,0 +1,54 @@
import { OrganisationGroupType, OrganisationMemberRole, TeamMemberRole } from '@prisma/client';
import { z } from 'zod';
import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
import { OrganisationGroupSchema } from '@documenso/prisma/generated/zod/modelSchema/OrganisationGroupSchema';
// export const getOrganisationGroupsMeta: TrpcOpenApiMeta = {
// openapi: {
// method: 'GET',
// path: '/organisation/{teamId}/groups',
// summary: 'Get organisation groups',
// description: 'Get all groups for a organisation',
// tags: ['Organisation'],
// },
// };
export const ZFindOrganisationGroupsRequestSchema = ZFindSearchParamsSchema.extend({
organisationId: z.string(),
organisationGroupId: z.string().optional(),
organisationRoles: z.nativeEnum(OrganisationMemberRole).array().optional(),
types: z.nativeEnum(OrganisationGroupType).array().optional(),
});
export const ZFindOrganisationGroupsResponseSchema = ZFindResultResponse.extend({
data: OrganisationGroupSchema.pick({
type: true,
organisationRole: true,
id: true,
name: true,
organisationId: true,
})
.extend({
members: z
.object({
id: z.string(),
userId: z.number(),
name: z.string(),
email: z.string(),
avatarImageId: z.string().nullable(),
})
.array(),
teams: z
.object({
id: z.number(),
name: z.string(),
teamGroupId: z.string(),
teamRole: z.nativeEnum(TeamMemberRole),
})
.array(),
})
.array(),
});
export type TFindOrganisationGroupsResponse = z.infer<typeof ZFindOrganisationGroupsResponseSchema>;

View File

@ -0,0 +1,105 @@
import type { OrganisationMemberInviteStatus } from '@prisma/client';
import { Prisma } from '@prisma/client';
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { FindResultResponse } from '@documenso/lib/types/search-params';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import {
ZFindOrganisationMemberInvitesRequestSchema,
ZFindOrganisationMemberInvitesResponseSchema,
} from './find-organisation-member-invites.types';
export const findOrganisationMemberInvitesRoute = authenticatedProcedure
// .meta(getOrganisationMemberInvitesMeta)
.input(ZFindOrganisationMemberInvitesRequestSchema)
.output(ZFindOrganisationMemberInvitesResponseSchema)
.query(async ({ input, ctx }) => {
const { organisationId, query, page, perPage, status } = input;
const { user } = ctx;
return await findOrganisationMemberInvites({
userId: user.id,
organisationId,
query,
page,
perPage,
status,
});
});
type FindOrganisationMemberInvitesOptions = {
userId: number;
organisationId: string;
query?: string;
page?: number;
perPage?: number;
status?: OrganisationMemberInviteStatus;
};
export const findOrganisationMemberInvites = async ({
userId,
organisationId,
query,
page = 1,
perPage = 10,
status,
}: FindOrganisationMemberInvitesOptions) => {
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({
organisationId,
userId,
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
}),
});
if (!organisation) {
throw new AppError(AppErrorCode.NOT_FOUND);
}
const whereClause: Prisma.OrganisationMemberInviteWhereInput = {
organisationId: organisation.id,
status,
};
if (query) {
whereClause.email = {
contains: query,
mode: Prisma.QueryMode.insensitive,
};
}
const [data, count] = await Promise.all([
prisma.organisationMemberInvite.findMany({
where: whereClause,
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
orderBy: {
createdAt: 'desc',
},
// Exclude token attribute.
select: {
id: true,
organisationId: true,
email: true,
createdAt: true,
organisationRole: true,
status: true,
},
}),
prisma.organisationMemberInvite.count({
where: whereClause,
}),
]);
return {
data,
count,
currentPage: Math.max(page, 1),
perPage,
totalPages: Math.ceil(count / perPage),
} satisfies FindResultResponse<typeof data>;
};

View File

@ -0,0 +1,35 @@
import { OrganisationMemberInviteStatus } from '@prisma/client';
import { z } from 'zod';
import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
import { OrganisationMemberInviteSchema } from '@documenso/prisma/generated/zod/modelSchema/OrganisationMemberInviteSchema';
// export const getOrganisationMemberInvitesMeta: TrpcOpenApiMeta = {
// openapi: {
// method: 'GET',
// path: '/organisation/{teamId}/members/pending',
// summary: 'Find organisation members pending',
// description: 'Find all members of a organisation pending',
// tags: ['Organisation'],
// },
// };
export const ZFindOrganisationMemberInvitesRequestSchema = ZFindSearchParamsSchema.extend({
organisationId: z.string(),
status: z.nativeEnum(OrganisationMemberInviteStatus).optional(),
});
export const ZFindOrganisationMemberInvitesResponseSchema = ZFindResultResponse.extend({
data: OrganisationMemberInviteSchema.pick({
id: true,
organisationId: true,
email: true,
createdAt: true,
organisationRole: true,
status: true,
}).array(),
});
export type TFindOrganisationMemberInvitesResponse = z.infer<
typeof ZFindOrganisationMemberInvitesResponseSchema
>;

View File

@ -0,0 +1,137 @@
import { Prisma } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { FindResultResponse } from '@documenso/lib/types/search-params';
import {
buildOrganisationWhereQuery,
getHighestOrganisationRoleInGroup,
} from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import {
ZFindOrganisationMembersRequestSchema,
ZFindOrganisationMembersResponseSchema,
} from './find-organisation-members.types';
export const findOrganisationMembersRoute = authenticatedProcedure
// .meta(getOrganisationMembersMeta)
.input(ZFindOrganisationMembersRequestSchema)
.output(ZFindOrganisationMembersResponseSchema)
.query(async ({ input, ctx }) => {
const { organisationId } = input;
const { id } = ctx.user;
const organisationMembers = await findOrganisationMembers({
userId: id,
organisationId,
query: input.query,
page: input.page,
perPage: input.perPage,
});
return {
...organisationMembers,
data: organisationMembers.data.map((organisationMember) => {
const groups = organisationMember.organisationGroupMembers.map((group) => group.group);
return {
id: organisationMember.id,
userId: organisationMember.user.id,
email: organisationMember.user.email,
name: organisationMember.user.name || '',
createdAt: organisationMember.createdAt,
currentOrganisationRole: getHighestOrganisationRoleInGroup(groups),
avatarImageId: organisationMember.user.avatarImageId,
groups,
};
}),
};
});
type FindOrganisationMembersOptions = {
userId: number;
organisationId: string;
query?: string;
page?: number;
perPage?: number;
};
export const findOrganisationMembers = async ({
userId,
organisationId,
query,
page = 1,
perPage = 10,
}: FindOrganisationMembersOptions) => {
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({ organisationId, userId }),
});
if (!organisation) {
throw new AppError(AppErrorCode.NOT_FOUND);
}
const whereClause: Prisma.OrganisationMemberWhereInput = {
organisationId: organisation.id,
};
if (query) {
whereClause.user = {
OR: [
{
email: {
contains: query,
mode: Prisma.QueryMode.insensitive,
},
},
{
name: {
contains: query,
mode: Prisma.QueryMode.insensitive,
},
},
],
};
}
const [data, count] = await Promise.all([
prisma.organisationMember.findMany({
where: whereClause,
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
orderBy: {
createdAt: 'desc',
},
select: {
id: true,
organisationId: true,
user: {
select: {
id: true,
email: true,
name: true,
avatarImageId: true,
},
},
organisationGroupMembers: {
select: {
group: true,
},
},
createdAt: true,
},
}),
prisma.organisationMember.count({
where: whereClause,
}),
]);
return {
data,
count,
currentPage: Math.max(page, 1),
perPage,
totalPages: Math.ceil(count / perPage),
} satisfies FindResultResponse<typeof data>;
};

View File

@ -0,0 +1,46 @@
import { z } from 'zod';
import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
import OrganisationMemberRoleSchema from '@documenso/prisma/generated/zod/inputTypeSchemas/OrganisationMemberRoleSchema';
import OrganisationGroupSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationGroupSchema';
import { OrganisationMemberSchema } from '@documenso/prisma/generated/zod/modelSchema/OrganisationMemberSchema';
// export const getOrganisationMembersMeta: TrpcOpenApiMeta = {
// openapi: {
// method: 'GET',
// path: '/organisation/{teamId}/members',
// summary: 'Find organisation members',
// description: 'Find all members of a organisation',
// tags: ['Organisation'],
// },
// };
export const ZFindOrganisationMembersRequestSchema = ZFindSearchParamsSchema.extend({
organisationId: z.string(),
});
export const ZFindOrganisationMembersResponseSchema = ZFindResultResponse.extend({
data: OrganisationMemberSchema.pick({
id: true,
createdAt: true,
userId: true,
})
.extend({
email: z.string(),
name: z.string(),
avatarImageId: z.string().nullable(),
currentOrganisationRole: OrganisationMemberRoleSchema,
groups: z.array(
OrganisationGroupSchema.pick({
id: true,
organisationRole: true,
type: true,
}),
),
})
.array(),
});
export type TFindOrganisationMembersResponse = z.infer<
typeof ZFindOrganisationMembersResponseSchema
>;

View File

@ -0,0 +1,34 @@
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import {
ZGetOrganisationMemberInvitesRequestSchema,
ZGetOrganisationMemberInvitesResponseSchema,
} from './get-organisation-member-invites.types';
export const getOrganisationMemberInvitesRoute = authenticatedProcedure
// .meta(getOrganisationMemberInvitesMeta)
.input(ZGetOrganisationMemberInvitesRequestSchema)
.output(ZGetOrganisationMemberInvitesResponseSchema)
.query(async ({ input, ctx }) => {
const { user } = ctx;
const { status } = input;
return await prisma.organisationMemberInvite.findMany({
where: {
email: user.email,
status,
},
include: {
organisation: {
select: {
id: true,
name: true,
url: true,
avatarImageId: true,
},
},
},
});
});

View File

@ -0,0 +1,30 @@
import { OrganisationMemberInviteStatus } from '@prisma/client';
import { z } from 'zod';
import { OrganisationMemberInviteSchema } from '@documenso/prisma/generated/zod/modelSchema/OrganisationMemberInviteSchema';
import OrganisationSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationSchema';
export const ZGetOrganisationMemberInvitesRequestSchema = z.object({
status: z.nativeEnum(OrganisationMemberInviteStatus).optional(),
});
export const ZGetOrganisationMemberInvitesResponseSchema = OrganisationMemberInviteSchema.pick({
id: true,
organisationId: true,
email: true,
createdAt: true,
token: true,
})
.extend({
organisation: OrganisationSchema.pick({
id: true,
name: true,
url: true,
avatarImageId: true,
}),
})
.array();
export type TGetOrganisationMemberInvitesResponse = z.infer<
typeof ZGetOrganisationMemberInvitesResponseSchema
>;

View File

@ -0,0 +1,79 @@
import { getHighestOrganisationRoleInGroup } from '@documenso/lib/utils/organisations';
import { buildTeamWhereQuery, getHighestTeamRoleInGroup } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import type { TGetOrganisationSessionResponse } from './get-organisation-session.types';
import { ZGetOrganisationSessionResponseSchema } from './get-organisation-session.types';
/**
* Get all the organisations and teams a user belongs to.
*/
export const getOrganisationSessionRoute = authenticatedProcedure
.output(ZGetOrganisationSessionResponseSchema)
.query(async ({ ctx }) => {
return await getOrganisationSession({ userId: ctx.user.id });
});
export const getOrganisationSession = async ({
userId,
}: {
userId: number;
}): Promise<TGetOrganisationSessionResponse> => {
const organisations = await prisma.organisation.findMany({
where: {
members: {
some: {
userId,
},
},
},
include: {
organisationClaim: true,
subscription: true,
groups: {
where: {
organisationGroupMembers: {
some: {
organisationMember: {
userId,
},
},
},
},
},
teams: {
where: buildTeamWhereQuery({ teamId: undefined, userId }),
include: {
teamGroups: {
where: {
organisationGroup: {
organisationGroupMembers: {
some: {
organisationMember: {
userId,
},
},
},
},
},
include: {
organisationGroup: true,
},
},
},
},
},
});
return organisations.map((organisation) => {
return {
...organisation,
teams: organisation.teams.map((team) => ({
...team,
currentTeamRole: getHighestTeamRoleInGroup(team.teamGroups),
})),
currentOrganisationRole: getHighestOrganisationRoleInGroup(organisation.groups),
};
});
};

View File

@ -0,0 +1,28 @@
import { z } from 'zod';
import { ZOrganisationSchema } from '@documenso/lib/types/organisation';
import { OrganisationMemberRole, TeamMemberRole } from '@documenso/prisma/generated/types';
import SubscriptionSchema from '@documenso/prisma/generated/zod/modelSchema/SubscriptionSchema';
import TeamSchema from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
export const ZGetOrganisationSessionResponseSchema = ZOrganisationSchema.extend({
teams: z.array(
TeamSchema.pick({
id: true,
name: true,
url: true,
createdAt: true,
avatarImageId: true,
organisationId: true,
}).extend({
currentTeamRole: z.nativeEnum(TeamMemberRole),
}),
),
subscription: SubscriptionSchema.nullable(),
currentOrganisationRole: z.nativeEnum(OrganisationMemberRole),
}).array();
export type TGetOrganisationSessionResponse = z.infer<typeof ZGetOrganisationSessionResponseSchema>;
export type TeamSession = TGetOrganisationSessionResponse[number]['teams'][number];
export type OrganisationSession = TGetOrganisationSessionResponse[number];

View File

@ -0,0 +1,79 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import {
ZGetOrganisationRequestSchema,
ZGetOrganisationResponseSchema,
} from './get-organisation.types';
export const getOrganisationRoute = authenticatedProcedure
// .meta(getOrganisationMeta)
.input(ZGetOrganisationRequestSchema)
.output(ZGetOrganisationResponseSchema)
.query(async ({ input, ctx }) => {
const { organisationReference } = input;
return await getOrganisation({
userId: ctx.user.id,
organisationReference,
});
});
type GetOrganisationOptions = {
userId: number;
/**
* The ID or URL of the organisation.
*/
organisationReference: string;
};
export const getOrganisation = async ({
userId,
organisationReference,
}: GetOrganisationOptions) => {
const organisation = await prisma.organisation.findFirst({
where: {
OR: [{ id: organisationReference }, { url: organisationReference }],
members: {
some: {
userId,
},
},
},
include: {
organisationGlobalSettings: true,
subscription: true,
organisationClaim: true,
teams: {
where: {
teamGroups: {
some: {
organisationGroup: {
organisationGroupMembers: {
some: {
organisationMember: {
userId,
},
},
},
},
},
},
},
},
},
});
if (!organisation) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Organisation not found',
});
}
return {
...organisation,
teams: organisation.teams,
};
};

View File

@ -0,0 +1,39 @@
import { z } from 'zod';
import { ZOrganisationSchema } from '@documenso/lib/types/organisation';
import OrganisationClaimSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationClaimSchema';
import OrganisationGlobalSettingsSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationGlobalSettingsSchema';
import SubscriptionSchema from '@documenso/prisma/generated/zod/modelSchema/SubscriptionSchema';
import TeamSchema from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
// export const getOrganisationMeta: TrpcOpenApiMeta = {
// openapi: {
// method: 'GET',
// path: '/organisation/{teamReference}',
// summary: 'Get organisation',
// description: 'Get an organisation by ID or URL',
// tags: ['Organisation'],
// },
// };
export const ZGetOrganisationRequestSchema = z.object({
organisationReference: z.string().describe('The ID or URL of the organisation.'),
});
export const ZGetOrganisationResponseSchema = ZOrganisationSchema.extend({
organisationGlobalSettings: OrganisationGlobalSettingsSchema,
organisationClaim: OrganisationClaimSchema,
subscription: SubscriptionSchema.nullable(),
teams: z.array(
TeamSchema.pick({
id: true,
name: true,
url: true,
createdAt: true,
avatarImageId: true,
organisationId: true,
}),
),
});
export type TGetOrganisationResponse = z.infer<typeof ZGetOrganisationResponseSchema>;

View File

@ -0,0 +1,58 @@
import { getHighestOrganisationRoleInGroup } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import {
ZGetOrganisationsRequestSchema,
ZGetOrganisationsResponseSchema,
} from './get-organisations.types';
export const getOrganisationsRoute = authenticatedProcedure
// .meta(getOrganisationsMeta)
.input(ZGetOrganisationsRequestSchema)
.output(ZGetOrganisationsResponseSchema)
.query(async ({ ctx }) => {
const { user } = ctx;
return getOrganisations({ userId: user.id });
});
export const getOrganisations = async ({ userId }: { userId: number }) => {
const organisations = await prisma.organisation.findMany({
where: {
members: {
some: {
userId,
},
},
},
include: {
members: {
where: {
userId,
},
},
groups: {
where: {
organisationGroupMembers: {
some: {
organisationMember: {
userId,
},
},
},
},
},
},
});
return organisations.map(({ groups, ...organisation }) => {
const currentOrganisationRole = getHighestOrganisationRoleInGroup(groups);
return {
...organisation,
currentOrganisationRole: currentOrganisationRole,
currentMemberId: organisation.members[0].id,
};
});
};

View File

@ -0,0 +1,23 @@
import { z } from 'zod';
import { ZOrganisationManySchema } from '@documenso/lib/types/organisation';
import OrganisationMemberRoleSchema from '@documenso/prisma/generated/zod/inputTypeSchemas/OrganisationMemberRoleSchema';
// export const getOrganisationsMeta: TrpcOpenApiMeta = {
// openapi: {
// method: 'GET',
// path: '/organisation/teams',
// summary: 'Get teams',
// description: 'Get all teams you are a member of',
// tags: ['Organisation'],
// },
// };
export const ZGetOrganisationsRequestSchema = z.void();
export const ZGetOrganisationsResponseSchema = ZOrganisationManySchema.extend({
currentOrganisationRole: OrganisationMemberRoleSchema,
currentMemberId: z.string(),
}).array();
export type TGetOrganisationsResponse = z.infer<typeof ZGetOrganisationsResponseSchema>;

View File

@ -0,0 +1,75 @@
import { syncMemberCountWithStripeSeatPlan } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { jobs } from '@documenso/lib/jobs/client';
import { validateIfSubscriptionIsRequired } from '@documenso/lib/utils/billing';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
import { OrganisationMemberInviteStatus } from '@documenso/prisma/client';
import { authenticatedProcedure } from '../trpc';
import {
ZLeaveOrganisationRequestSchema,
ZLeaveOrganisationResponseSchema,
} from './leave-organisation.types';
export const leaveOrganisationRoute = authenticatedProcedure
.input(ZLeaveOrganisationRequestSchema)
.output(ZLeaveOrganisationResponseSchema)
.mutation(async ({ ctx, input }) => {
const { organisationId } = input;
const userId = ctx.user.id;
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({ organisationId, userId }),
include: {
organisationClaim: true,
subscription: true,
invites: {
where: {
status: OrganisationMemberInviteStatus.PENDING,
},
select: {
id: true,
},
},
members: {
select: {
id: true,
},
},
},
});
if (!organisation) {
throw new AppError(AppErrorCode.NOT_FOUND);
}
const { organisationClaim } = organisation;
const subscription = validateIfSubscriptionIsRequired(organisation.subscription);
const inviteCount = organisation.invites.length;
const newMemberCount = organisation.members.length + inviteCount - 1;
if (subscription) {
await syncMemberCountWithStripeSeatPlan(subscription, organisationClaim, newMemberCount);
}
await prisma.organisationMember.delete({
where: {
userId_organisationId: {
userId,
organisationId,
},
},
});
await jobs.triggerJob({
name: 'send.organisation-member-left.email',
payload: {
organisationId: organisation.id,
memberUserId: userId,
},
});
});

View File

@ -0,0 +1,7 @@
import { z } from 'zod';
export const ZLeaveOrganisationRequestSchema = z.object({
organisationId: z.string(),
});
export const ZLeaveOrganisationResponseSchema = z.void();

View File

@ -0,0 +1,99 @@
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { sendOrganisationMemberInviteEmail } from '@documenso/lib/server-only/organisation/create-organisation-member-invites';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import {
ZResendOrganisationMemberInviteRequestSchema,
ZResendOrganisationMemberInviteResponseSchema,
} from './resend-organisation-member-invite.types';
export const resendOrganisationMemberInviteRoute = authenticatedProcedure
// .meta(resendOrganisationMemberInviteMeta)
.input(ZResendOrganisationMemberInviteRequestSchema)
.output(ZResendOrganisationMemberInviteResponseSchema)
.mutation(async ({ ctx, input }) => {
const { organisationId, invitationId } = input;
const userId = ctx.user.id;
const userName = ctx.user.name || '';
await resendOrganisationMemberInvitation({
userId,
userName,
organisationId,
invitationId,
});
});
export type ResendOrganisationMemberInvitationOptions = {
/**
* The ID of the user who is initiating this action.
*/
userId: number;
/**
* The name of the user who is initiating this action.
*/
userName: string;
/**
* The ID of the organisation.
*/
organisationId: string;
/**
* The IDs of the invitations to resend.
*/
invitationId: string;
};
/**
* Resend an email for a given member invite.
*/
export const resendOrganisationMemberInvitation = async ({
userId,
userName,
organisationId,
invitationId,
}: ResendOrganisationMemberInvitationOptions): Promise<void> => {
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({
organisationId,
userId,
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
}),
include: {
organisationGlobalSettings: true,
invites: {
where: {
id: invitationId,
},
},
},
});
if (!organisation) {
throw new AppError('OrganisationNotFound', {
message: 'User is not a valid member of the team.',
statusCode: 404,
});
}
const invitation = organisation.invites[0];
if (!invitation) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Invitation does not exist',
});
}
await sendOrganisationMemberInviteEmail({
email: invitation.email,
token: invitation.token,
senderName: userName,
organisation,
});
};

View File

@ -0,0 +1,18 @@
import { z } from 'zod';
// export const resendOrganisationMemberInviteMeta: TrpcOpenApiMeta = {
// openapi: {
// method: 'POST',
// path: '/organisation/member/resend-invite',
// summary: 'Resend organisation member invite',
// description: 'Resend a organisation member invite',
// tags: ['Organisation'],
// },
// };
export const ZResendOrganisationMemberInviteRequestSchema = z.object({
organisationId: z.string(),
invitationId: z.string(),
});
export const ZResendOrganisationMemberInviteResponseSchema = z.void();

View File

@ -0,0 +1,60 @@
import { router } from '../trpc';
import { acceptOrganisationMemberInviteRoute } from './accept-organisation-member-invite';
import { createOrganisationRoute } from './create-organisation';
import { createOrganisationGroupRoute } from './create-organisation-group';
import { createOrganisationMemberInvitesRoute } from './create-organisation-member-invites';
import { declineOrganisationMemberInviteRoute } from './decline-organisation-member-invite';
import { deleteOrganisationRoute } from './delete-organisation';
import { deleteOrganisationGroupRoute } from './delete-organisation-group';
import { deleteOrganisationMemberRoute } from './delete-organisation-member';
import { deleteOrganisationMemberInvitesRoute } from './delete-organisation-member-invites';
import { deleteOrganisationMembersRoute } from './delete-organisation-members';
import { findOrganisationGroupsRoute } from './find-organisation-groups';
import { findOrganisationMemberInvitesRoute } from './find-organisation-member-invites';
import { findOrganisationMembersRoute } from './find-organisation-members';
import { getOrganisationRoute } from './get-organisation';
import { getOrganisationMemberInvitesRoute } from './get-organisation-member-invites';
import { getOrganisationSessionRoute } from './get-organisation-session';
import { getOrganisationsRoute } from './get-organisations';
import { leaveOrganisationRoute } from './leave-organisation';
import { resendOrganisationMemberInviteRoute } from './resend-organisation-member-invite';
import { updateOrganisationRoute } from './update-organisation';
import { updateOrganisationGroupRoute } from './update-organisation-group';
import { updateOrganisationMemberRoute } from './update-organisation-members';
import { updateOrganisationSettingsRoute } from './update-organisation-settings';
export const organisationRouter = router({
get: getOrganisationRoute,
getMany: getOrganisationsRoute,
create: createOrganisationRoute,
update: updateOrganisationRoute,
delete: deleteOrganisationRoute,
leave: leaveOrganisationRoute,
member: {
find: findOrganisationMembersRoute,
update: updateOrganisationMemberRoute,
delete: deleteOrganisationMemberRoute,
deleteMany: deleteOrganisationMembersRoute,
invite: {
find: findOrganisationMemberInvitesRoute,
getMany: getOrganisationMemberInvitesRoute,
createMany: createOrganisationMemberInvitesRoute,
deleteMany: deleteOrganisationMemberInvitesRoute,
accept: acceptOrganisationMemberInviteRoute,
decline: declineOrganisationMemberInviteRoute,
resend: resendOrganisationMemberInviteRoute,
},
},
group: {
find: findOrganisationGroupsRoute,
create: createOrganisationGroupRoute,
update: updateOrganisationGroupRoute,
delete: deleteOrganisationGroupRoute,
},
settings: {
update: updateOrganisationSettingsRoute,
},
internal: {
getOrganisationSession: getOrganisationSessionRoute,
},
});

View File

@ -0,0 +1,127 @@
import { unique } from 'remeda';
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getMemberOrganisationRole } from '@documenso/lib/server-only/team/get-member-roles';
import { generateDatabaseId } from '@documenso/lib/universal/id';
import {
buildOrganisationWhereQuery,
isOrganisationRoleWithinUserHierarchy,
} from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
import { OrganisationGroupType } from '@documenso/prisma/generated/types';
import { authenticatedProcedure } from '../trpc';
import {
ZUpdateOrganisationGroupRequestSchema,
ZUpdateOrganisationGroupResponseSchema,
} from './update-organisation-group.types';
export const updateOrganisationGroupRoute = authenticatedProcedure
// .meta(updateOrganisationGroupMeta)
.input(ZUpdateOrganisationGroupRequestSchema)
.output(ZUpdateOrganisationGroupResponseSchema)
.mutation(async ({ input, ctx }) => {
const { id, ...data } = input;
const { user } = ctx;
const organisationGroup = await prisma.organisationGroup.findFirst({
where: {
id,
organisation: buildOrganisationWhereQuery({
organisationId: undefined,
userId: user.id,
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
}),
},
include: {
organisationGroupMembers: true,
},
});
if (!organisationGroup) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Organisation group not found',
});
}
if (organisationGroup.type === OrganisationGroupType.INTERNAL_ORGANISATION) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You are not allowed to update internal organisation groups',
});
}
const currentUserOrganisationRole = await getMemberOrganisationRole({
organisationId: organisationGroup.organisationId,
reference: {
type: 'User',
id: user.id,
},
});
if (
!isOrganisationRoleWithinUserHierarchy(
currentUserOrganisationRole,
organisationGroup.organisationRole,
)
) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You are not allowed to update this organisation group',
});
}
if (
data.organisationRole &&
!isOrganisationRoleWithinUserHierarchy(currentUserOrganisationRole, data.organisationRole)
) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You are not allowed to set an organisation role higher than your own',
});
}
const groupMemberIds = unique(data.memberIds || []);
const membersToDelete = organisationGroup.organisationGroupMembers.filter(
(member) => !groupMemberIds.includes(member.organisationMemberId),
);
const membersToCreate = groupMemberIds.filter(
(id) =>
!organisationGroup.organisationGroupMembers.some(
(member) => member.organisationMemberId === id,
),
);
await prisma.$transaction(async (tx) => {
await tx.organisationGroup.update({
where: {
id,
},
data: {
organisationRole: data.organisationRole,
name: data.name,
},
});
// Only run deletion if memberIds is defined.
if (data.memberIds && membersToDelete.length > 0) {
await tx.organisationGroupMember.deleteMany({
where: {
groupId: organisationGroup.id,
organisationMemberId: { in: membersToDelete.map((m) => m.organisationMemberId) },
},
});
}
// Only run creation if memberIds is defined.
if (data.memberIds && membersToCreate.length > 0) {
await tx.organisationGroupMember.createMany({
data: membersToCreate.map((id) => ({
id: generateDatabaseId('group_member'),
groupId: organisationGroup.id,
organisationMemberId: id,
})),
});
}
});
});

View File

@ -0,0 +1,24 @@
import { OrganisationMemberRole } from '@prisma/client';
import { z } from 'zod';
// export const updateOrganisationGroupMeta: TrpcOpenApiMeta = {
// openapi: {
// method: 'POST',
// path: '/organisation/groups/{id}',
// summary: 'Update organisation group',
// description: 'Update an existing group for a organisation',
// tags: ['Organisation'],
// requiredScopes: ['personal:organisation:write'],
// },
// };
export const ZUpdateOrganisationGroupRequestSchema = z.object({
id: z.string(),
name: z.string().nullable().optional(),
organisationRole: z.nativeEnum(OrganisationMemberRole).optional(),
memberIds: z.array(z.string()).optional(),
});
export const ZUpdateOrganisationGroupResponseSchema = z.void();
export type TUpdateOrganisationGroupRequest = z.infer<typeof ZUpdateOrganisationGroupRequestSchema>;

View File

@ -0,0 +1,156 @@
import { OrganisationGroupType } from '@prisma/client';
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { generateDatabaseId } from '@documenso/lib/universal/id';
import {
buildOrganisationWhereQuery,
getHighestOrganisationRoleInGroup,
isOrganisationRoleWithinUserHierarchy,
} from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import {
ZUpdateOrganisationMemberRequestSchema,
ZUpdateOrganisationMemberResponseSchema,
} from './update-organisation-members.types';
export const updateOrganisationMemberRoute = authenticatedProcedure
// .meta(updateOrganisationMemberMeta)
.input(ZUpdateOrganisationMemberRequestSchema)
.output(ZUpdateOrganisationMemberResponseSchema)
.mutation(async ({ ctx, input }) => {
const { organisationId, organisationMemberId, data } = input;
const userId = ctx.user.id;
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({
organisationId,
userId,
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
}),
include: {
groups: {
where: {
type: OrganisationGroupType.INTERNAL_ORGANISATION,
},
},
members: {
include: {
organisationGroupMembers: {
include: {
group: true,
},
},
user: {
select: {
id: true,
name: true,
email: true,
},
},
},
},
},
});
if (!organisation) {
throw new AppError(AppErrorCode.NOT_FOUND, { message: 'Organisation not found' });
}
const currentUser = organisation.members.find((member) => member.userId === userId);
const organisationMemberToUpdate = organisation.members.find(
(member) => member.id === organisationMemberId,
);
if (!organisationMemberToUpdate || !currentUser) {
throw new AppError(AppErrorCode.NOT_FOUND, { message: 'Organisation member does not exist' });
}
if (organisationMemberToUpdate.userId === organisation.ownerUserId) {
throw new AppError(AppErrorCode.UNAUTHORIZED, { message: 'Cannot update the owner' });
}
const currentUserOrganisationRoles = currentUser.organisationGroupMembers.filter(
({ group }) => group.type === OrganisationGroupType.INTERNAL_ORGANISATION,
);
if (currentUserOrganisationRoles.length !== 1) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Current user has multiple internal organisation roles',
});
}
const currentUserOrganisationRole = currentUserOrganisationRoles[0].group.organisationRole;
const currentMemberToUpdateOrganisationRole = getHighestOrganisationRoleInGroup(
organisationMemberToUpdate.organisationGroupMembers.flatMap((member) => member.group),
);
const isMemberToUpdateHigherRole = !isOrganisationRoleWithinUserHierarchy(
currentUserOrganisationRole,
currentMemberToUpdateOrganisationRole,
);
if (isMemberToUpdateHigherRole) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Cannot update a member with a higher role',
});
}
const isNewMemberRoleHigherThanCurrentRole = !isOrganisationRoleWithinUserHierarchy(
currentUserOrganisationRole,
data.role,
);
if (isNewMemberRoleHigherThanCurrentRole) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Cannot give a member a role higher than the user initating the update',
});
}
const currentMemberGroup = organisation.groups.find(
(group) => group.organisationRole === currentMemberToUpdateOrganisationRole,
);
const newMemberGroup = organisation.groups.find(
(group) => group.organisationRole === data.role,
);
if (!currentMemberGroup) {
console.error('[CRITICAL]: Missing internal group');
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Current member group not found',
});
}
if (!newMemberGroup) {
console.error('[CRITICAL]: Missing internal group');
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'New member group not found',
});
}
// Switch member to new internal group role.
await prisma.$transaction(async (tx) => {
await tx.organisationGroupMember.delete({
where: {
organisationMemberId_groupId: {
organisationMemberId: organisationMemberToUpdate.id,
groupId: currentMemberGroup.id,
},
},
});
await tx.organisationGroupMember.create({
data: {
id: generateDatabaseId('group_member'),
organisationMemberId: organisationMemberToUpdate.id,
groupId: newMemberGroup.id,
},
});
});
});

View File

@ -0,0 +1,22 @@
import { OrganisationMemberRole } from '@prisma/client';
import { z } from 'zod';
// export const updateOrganisationMemberMeta: TrpcOpenApiMeta = {
// openapi: {
// method: 'POST',
// path: '/organisation/member/update',
// summary: 'Update organisation member',
// description: 'Update organisation member',
// tags: ['Organisation'],
// },
// };
export const ZUpdateOrganisationMemberRequestSchema = z.object({
organisationId: z.string(),
organisationMemberId: z.string(),
data: z.object({
role: z.nativeEnum(OrganisationMemberRole),
}),
});
export const ZUpdateOrganisationMemberResponseSchema = z.void();

View File

@ -0,0 +1,100 @@
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import {
ZUpdateOrganisationSettingsRequestSchema,
ZUpdateOrganisationSettingsResponseSchema,
} from './update-organisation-settings.types';
export const updateOrganisationSettingsRoute = authenticatedProcedure
.input(ZUpdateOrganisationSettingsRequestSchema)
.output(ZUpdateOrganisationSettingsResponseSchema)
.mutation(async ({ ctx, input }) => {
const { user } = ctx;
const { organisationId, data } = input;
const {
// Document related settings.
documentVisibility,
documentLanguage,
includeSenderDetails,
includeSigningCertificate,
typedSignatureEnabled,
uploadSignatureEnabled,
drawSignatureEnabled,
// Branding related settings.
brandingEnabled,
brandingLogo,
brandingUrl,
brandingCompanyDetails,
} = data;
if (Object.values(data).length === 0) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'No settings to update',
});
}
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({
organisationId,
userId: user.id,
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
}),
include: {
organisationGlobalSettings: true,
},
});
if (!organisation) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to update this organisation.',
});
}
const derivedTypedSignatureEnabled =
typedSignatureEnabled ?? organisation.organisationGlobalSettings.typedSignatureEnabled;
const derivedUploadSignatureEnabled =
uploadSignatureEnabled ?? organisation.organisationGlobalSettings.uploadSignatureEnabled;
const derivedDrawSignatureEnabled =
drawSignatureEnabled ?? organisation.organisationGlobalSettings.drawSignatureEnabled;
if (
derivedTypedSignatureEnabled === false &&
derivedUploadSignatureEnabled === false &&
derivedDrawSignatureEnabled === false
) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'At least one signature type must be enabled',
});
}
await prisma.organisation.update({
where: {
id: organisationId,
},
data: {
organisationGlobalSettings: {
update: {
// Document related settings.
documentVisibility,
documentLanguage,
includeSenderDetails,
includeSigningCertificate,
typedSignatureEnabled,
uploadSignatureEnabled,
drawSignatureEnabled,
// Branding related settings.
brandingEnabled,
brandingLogo,
brandingUrl,
brandingCompanyDetails,
},
},
},
});
});

View File

@ -0,0 +1,26 @@
import { z } from 'zod';
import { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/i18n';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
export const ZUpdateOrganisationSettingsRequestSchema = z.object({
organisationId: z.string(),
data: z.object({
// Document related settings.
documentVisibility: z.nativeEnum(DocumentVisibility).optional(),
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES).optional(),
includeSenderDetails: z.boolean().optional(),
includeSigningCertificate: z.boolean().optional(),
typedSignatureEnabled: z.boolean().optional(),
uploadSignatureEnabled: z.boolean().optional(),
drawSignatureEnabled: z.boolean().optional(),
// Branding related settings.
brandingEnabled: z.boolean().optional(),
brandingLogo: z.string().optional(),
brandingUrl: z.string().optional(),
brandingCompanyDetails: z.string().optional(),
}),
});
export const ZUpdateOrganisationSettingsResponseSchema = z.void();

View File

@ -0,0 +1,45 @@
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
import {
ZUpdateOrganisationRequestSchema,
ZUpdateOrganisationResponseSchema,
} from './update-organisation.types';
export const updateOrganisationRoute = authenticatedProcedure
// .meta(updateOrganisationMeta)
.input(ZUpdateOrganisationRequestSchema)
.output(ZUpdateOrganisationResponseSchema)
.mutation(async ({ input, ctx }) => {
const { organisationId, data } = input;
const userId = ctx.user.id;
// Check if organisation exists and user has access to it
const existingOrganisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({
organisationId,
userId,
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
}),
});
if (!existingOrganisation) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Organisation not found',
});
}
await prisma.organisation.update({
where: {
id: organisationId,
},
data: {
name: data.name,
url: data.url,
},
});
});

View File

@ -0,0 +1,25 @@
import { z } from 'zod';
import { ZTeamUrlSchema } from '../team-router/schema';
import { ZCreateOrganisationRequestSchema } from './create-organisation.types';
// export const updateOrganisationMeta: TrpcOpenApiMeta = {
// openapi: {
// method: 'POST',
// path: '/organisation/{teamId}',
// summary: 'Update organisation',
// description: 'Update an organisation',
// tags: ['Organisation'],
// },
// };
export const ZUpdateOrganisationRequestSchema = z.object({
data: ZCreateOrganisationRequestSchema.pick({
name: true,
}).extend({
url: ZTeamUrlSchema,
}),
organisationId: z.string(),
});
export const ZUpdateOrganisationResponseSchema = z.void();