feat: init
This commit is contained in:
110
packages/email/templates/organisation-invite.tsx
Normal file
110
packages/email/templates/organisation-invite.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { formatOrganisationUrl } from '@documenso/lib/utils/organisations';
|
||||
import config from '@documenso/tailwind-config';
|
||||
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Hr,
|
||||
Html,
|
||||
Preview,
|
||||
Section,
|
||||
Tailwind,
|
||||
Text,
|
||||
} from '../components';
|
||||
import { TemplateFooter } from '../template-components/template-footer';
|
||||
import TemplateImage from '../template-components/template-image';
|
||||
|
||||
export type OrganisationInviteEmailProps = {
|
||||
assetBaseUrl: string;
|
||||
baseUrl: string;
|
||||
senderName: string;
|
||||
organisationName: string;
|
||||
organisationUrl: string;
|
||||
token: string;
|
||||
};
|
||||
|
||||
export const OrganisationInviteEmailTemplate = ({
|
||||
assetBaseUrl = 'http://localhost:3002',
|
||||
baseUrl = 'https://documenso.com',
|
||||
senderName = 'John Doe',
|
||||
organisationName = 'Organisation Name',
|
||||
organisationUrl = 'demo',
|
||||
token = '',
|
||||
}: OrganisationInviteEmailProps) => {
|
||||
const previewText = `Accept invitation to join a organisation on Documenso`;
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: config.theme.extend.colors,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Section className="bg-white text-slate-500">
|
||||
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-2 backdrop-blur-sm">
|
||||
<TemplateImage
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
className="mb-4 h-6 p-2"
|
||||
staticAsset="logo.png"
|
||||
/>
|
||||
|
||||
<Section>
|
||||
<TemplateImage
|
||||
className="mx-auto"
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
staticAsset="add-user.png"
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section className="p-2 text-slate-500">
|
||||
<Text className="text-center text-lg font-medium text-black">
|
||||
Join {organisationName} on Documenso
|
||||
</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base">
|
||||
You have been invited to join the following organisation
|
||||
</Text>
|
||||
|
||||
<div className="mx-auto my-2 w-fit rounded-lg bg-gray-50 px-4 py-2 text-base font-medium text-slate-600">
|
||||
{formatOrganisationUrl(organisationUrl, baseUrl)}
|
||||
</div>
|
||||
|
||||
<Text className="my-1 text-center text-base">
|
||||
by <span className="text-slate-900">{senderName}</span>
|
||||
</Text>
|
||||
|
||||
{/* Todo: Orgs - Display warnings. */}
|
||||
|
||||
<Section className="mb-6 mt-6 text-center">
|
||||
<Button
|
||||
className="bg-documenso-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
|
||||
href={`${baseUrl}/organisation/invite/${token}`}
|
||||
>
|
||||
Accept
|
||||
</Button>
|
||||
</Section>
|
||||
</Section>
|
||||
</Container>
|
||||
|
||||
<Hr className="mx-auto mt-12 max-w-xl" />
|
||||
|
||||
<Container className="mx-auto max-w-xl">
|
||||
<TemplateFooter isDocument={false} />
|
||||
</Container>
|
||||
</Section>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrganisationInviteEmailTemplate;
|
||||
30
packages/lib/constants/organisations.ts
Normal file
30
packages/lib/constants/organisations.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { OrganisationMemberRole } from '@documenso/prisma/client';
|
||||
|
||||
export const ORGANISATION_URL_ROOT_REGEX = new RegExp('^/orgs/[^/]+$');
|
||||
export const ORGANISATION_URL_REGEX = new RegExp('^/orgs/[^/]+');
|
||||
|
||||
export const ORGANISATION_MEMBER_ROLE_MAP: Record<keyof typeof OrganisationMemberRole, string> = {
|
||||
ADMIN: 'Admin',
|
||||
MANAGER: 'Manager',
|
||||
MEMBER: 'Member',
|
||||
};
|
||||
|
||||
// Todo: Orgs
|
||||
export const ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP = {
|
||||
MANAGE_ORGANISATION: [OrganisationMemberRole.ADMIN, OrganisationMemberRole.MANAGER],
|
||||
MANAGE_BILLING: [OrganisationMemberRole.ADMIN],
|
||||
DELETE_ORGANISATION_TRANSFER_REQUEST: [OrganisationMemberRole.ADMIN],
|
||||
} satisfies Record<string, OrganisationMemberRole[]>;
|
||||
|
||||
/**
|
||||
* A hierarchy of member roles to determine which role has higher permission than another.
|
||||
*/
|
||||
export const ORGANISATION_MEMBER_ROLE_HIERARCHY = {
|
||||
[OrganisationMemberRole.ADMIN]: [
|
||||
OrganisationMemberRole.ADMIN,
|
||||
OrganisationMemberRole.MANAGER,
|
||||
OrganisationMemberRole.MEMBER,
|
||||
],
|
||||
[OrganisationMemberRole.MANAGER]: [OrganisationMemberRole.MANAGER, OrganisationMemberRole.MEMBER],
|
||||
[OrganisationMemberRole.MEMBER]: [OrganisationMemberRole.MEMBER],
|
||||
} satisfies Record<OrganisationMemberRole, OrganisationMemberRole[]>;
|
||||
@@ -79,6 +79,10 @@ export const PROTECTED_TEAM_URLS = [
|
||||
'logout',
|
||||
'maintenance',
|
||||
'malware',
|
||||
'org',
|
||||
'orgs',
|
||||
'organisation',
|
||||
'organisations',
|
||||
'newsletter',
|
||||
'policy',
|
||||
'privacy',
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { OrganisationMemberStatus } from '@documenso/prisma/client';
|
||||
|
||||
export type AcceptOrganisationInvitationOptions = {
|
||||
userId: number;
|
||||
organisationId: string;
|
||||
};
|
||||
|
||||
export const acceptOrganisationInvitation = async ({
|
||||
userId,
|
||||
organisationId,
|
||||
}: AcceptOrganisationInvitationOptions) => {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const user = await tx.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
const organisationMemberInvite = await tx.organisationMemberInvite.findFirstOrThrow({
|
||||
where: {
|
||||
organisationId,
|
||||
email: user.email,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.organisationMember.create({
|
||||
data: {
|
||||
name: user.name ?? '',
|
||||
status: OrganisationMemberStatus.ACTIVE,
|
||||
organisationId: organisationMemberInvite.organisationId,
|
||||
userId: user.id,
|
||||
role: organisationMemberInvite.role,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.organisationMemberInvite.delete({
|
||||
where: {
|
||||
id: organisationMemberInvite.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,166 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { render } from '@documenso/email/render';
|
||||
import type { OrganisationInviteEmailProps } from '@documenso/email/templates/organisation-invite';
|
||||
import { OrganisationInviteEmailTemplate } from '@documenso/email/templates/organisation-invite';
|
||||
import { WEBAPP_BASE_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 { InviteStatus } from '@documenso/prisma/client';
|
||||
import type { TCreateOrganisationMemberInvitesMutationSchema } from '@documenso/trpc/server/organisation-router/schema';
|
||||
|
||||
export type CreateOrganisationMemberInvitesOptions = {
|
||||
userId: number;
|
||||
userName: string;
|
||||
organisationId: string;
|
||||
invitations: TCreateOrganisationMemberInvitesMutationSchema['invitations'];
|
||||
};
|
||||
|
||||
/**
|
||||
* Invite organisation members via email to join a organisation.
|
||||
*/
|
||||
export const createOrganisationMemberInvites = async ({
|
||||
userId,
|
||||
userName,
|
||||
organisationId,
|
||||
invitations,
|
||||
}: CreateOrganisationMemberInvitesOptions) => {
|
||||
const organisation = await prisma.organisation.findFirstOrThrow({
|
||||
where: {
|
||||
id: organisationId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
role: {
|
||||
in: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
members: {
|
||||
select: {
|
||||
role: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
invites: true,
|
||||
},
|
||||
});
|
||||
|
||||
const organisationMemberEmails = organisation.members.map((member) => member.user.email);
|
||||
const organisationMemberInviteEmails = organisation.invites.map((invite) => invite.email);
|
||||
const currentOrganisationMember = organisation.members.find(
|
||||
(member) => member.user.id === userId,
|
||||
);
|
||||
|
||||
if (!currentOrganisationMember) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, '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(
|
||||
({ role }) => !isOrganisationRoleWithinUserHierarchy(currentOrganisationMember.role, role),
|
||||
);
|
||||
|
||||
if (unauthorizedRoleAccess) {
|
||||
throw new AppError(
|
||||
AppErrorCode.UNAUTHORIZED,
|
||||
'User does not have permission to set high level roles',
|
||||
);
|
||||
}
|
||||
|
||||
const organisationMemberInvites = usersToInvite.map(({ email, role }) => ({
|
||||
email,
|
||||
organisationId,
|
||||
role,
|
||||
status: InviteStatus.PENDING,
|
||||
token: nanoid(32),
|
||||
}));
|
||||
|
||||
await prisma.organisationMemberInvite.createMany({
|
||||
data: organisationMemberInvites,
|
||||
});
|
||||
|
||||
const sendEmailResult = await Promise.allSettled(
|
||||
organisationMemberInvites.map(async ({ email, token }) =>
|
||||
sendOrganisationMemberInviteEmail({
|
||||
email,
|
||||
token,
|
||||
organisationName: organisation.name,
|
||||
organisationUrl: organisation.url,
|
||||
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',
|
||||
'Failed to send invite emails to one or more users.',
|
||||
`Failed to send invites to ${sendEmailResultErrorList.length}/${organisationMemberInvites.length} users.`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
type SendOrganisationMemberInviteEmailOptions = Omit<
|
||||
OrganisationInviteEmailProps,
|
||||
'baseUrl' | 'assetBaseUrl'
|
||||
> & {
|
||||
email: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Send an email to a user inviting them to join a organisation.
|
||||
*/
|
||||
export const sendOrganisationMemberInviteEmail = async ({
|
||||
email,
|
||||
...emailTemplateOptions
|
||||
}: SendOrganisationMemberInviteEmailOptions) => {
|
||||
const template = createElement(OrganisationInviteEmailTemplate, {
|
||||
assetBaseUrl: WEBAPP_BASE_URL,
|
||||
baseUrl: WEBAPP_BASE_URL,
|
||||
...emailTemplateOptions,
|
||||
});
|
||||
|
||||
await mailer.sendMail({
|
||||
to: email,
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: `You have been invited to join ${emailTemplateOptions.organisationName} on Documenso`,
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
});
|
||||
};
|
||||
82
packages/lib/server-only/organisation/create-organisation.ts
Normal file
82
packages/lib/server-only/organisation/create-organisation.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { OrganisationMemberRole, OrganisationMemberStatus, Prisma } from '@documenso/prisma/client';
|
||||
|
||||
export type CreateOrganisationOptions = {
|
||||
/**
|
||||
* ID of the user creating the Team.
|
||||
*/
|
||||
userId: number;
|
||||
|
||||
/**
|
||||
* Name of the organisation to display.
|
||||
*/
|
||||
organisationName: string;
|
||||
|
||||
/**
|
||||
* Unique URL of the organisation.
|
||||
*
|
||||
* Used as the URL path, example: https://documenso.com/orgs/{orgUrl}/settings
|
||||
*/
|
||||
organisationUrl: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create an organisation.
|
||||
*/
|
||||
export const createOrganisation = async ({
|
||||
userId,
|
||||
organisationName,
|
||||
organisationUrl,
|
||||
}: CreateOrganisationOptions): Promise<void> => {
|
||||
const user = await prisma.user.findUniqueOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
include: {
|
||||
Subscription: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Todo: Orgs - max 1 org per enterprise user & billing must be enabled, active, etc
|
||||
if (!IS_BILLING_ENABLED()) {
|
||||
throw new AppError('TODO');
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.organisation.create({
|
||||
data: {
|
||||
name: organisationName,
|
||||
url: organisationUrl,
|
||||
ownerUserId: user.id,
|
||||
members: {
|
||||
create: [
|
||||
{
|
||||
name: user.name ?? '',
|
||||
userId,
|
||||
status: OrganisationMemberStatus.ACTIVE,
|
||||
role: OrganisationMemberRole.ADMIN,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
if (!(err instanceof Prisma.PrismaClientKnownRequestError)) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const target = z.array(z.string()).safeParse(err.meta?.target);
|
||||
|
||||
if (err.code === 'P2002' && target.success && target.data.includes('url')) {
|
||||
throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Organisation URL already exists.');
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/organisations';
|
||||
|
||||
export type DeleteTeamMemberInvitationsOptions = {
|
||||
/**
|
||||
* The ID of the user who is initiating this action.
|
||||
*/
|
||||
userId: number;
|
||||
organisationId: string;
|
||||
invitationIds: string[];
|
||||
};
|
||||
|
||||
export const deleteOrganisationMemberInvitations = async ({
|
||||
userId,
|
||||
organisationId,
|
||||
invitationIds,
|
||||
}: DeleteTeamMemberInvitationsOptions) => {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.organisationMember.findFirstOrThrow({
|
||||
where: {
|
||||
userId,
|
||||
organisationId,
|
||||
role: {
|
||||
in: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await tx.organisationMemberInvite.deleteMany({
|
||||
where: {
|
||||
id: {
|
||||
in: invitationIds,
|
||||
},
|
||||
organisationId,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { isOrganisationRoleWithinUserHierarchy } from '@documenso/lib/utils/organisations';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/organisations';
|
||||
|
||||
export type DeleteOrganisationMembersOptions = {
|
||||
/**
|
||||
* The ID of the user who is initiating this action.
|
||||
*/
|
||||
userId: number;
|
||||
|
||||
/**
|
||||
* The ID of the organisation to remove members from.
|
||||
*/
|
||||
organisationId: string;
|
||||
|
||||
/**
|
||||
* The IDs of the members to remove.
|
||||
*/
|
||||
memberIds: string[];
|
||||
};
|
||||
|
||||
export const deleteOrganisationMembers = async ({
|
||||
userId,
|
||||
organisationId,
|
||||
memberIds,
|
||||
}: DeleteOrganisationMembersOptions) => {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Find the organisation and validate that the user is allowed to remove members.
|
||||
const organisation = await tx.organisation.findFirstOrThrow({
|
||||
where: {
|
||||
id: organisationId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
role: {
|
||||
in: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
members: {
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const currentMember = organisation.members.find((member) => member.userId === userId);
|
||||
const membersToRemove = organisation.members.filter((member) => memberIds.includes(member.id));
|
||||
|
||||
if (!currentMember) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, 'Organisation member record does not exist');
|
||||
}
|
||||
|
||||
if (membersToRemove.find((member) => member.userId === organisation.ownerUserId)) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Cannot remove the organisation owner');
|
||||
}
|
||||
|
||||
const isMemberToRemoveHigherRole = membersToRemove.some(
|
||||
(member) => !isOrganisationRoleWithinUserHierarchy(currentMember.role, member.role),
|
||||
);
|
||||
|
||||
if (isMemberToRemoveHigherRole) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Cannot remove a member with a higher role');
|
||||
}
|
||||
|
||||
// Remove the members.
|
||||
await tx.organisationMember.deleteMany({
|
||||
where: {
|
||||
id: {
|
||||
in: memberIds,
|
||||
},
|
||||
organisationId,
|
||||
userId: {
|
||||
not: organisation.ownerUserId,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
import { P, match } from 'ts-pattern';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { OrganisationMemberInvite } from '@documenso/prisma/client';
|
||||
import { Prisma } from '@documenso/prisma/client';
|
||||
|
||||
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/organisations';
|
||||
import type { FindResultSet } from '../../types/find-result-set';
|
||||
|
||||
export interface FindOrganisationMemberInvitesOptions {
|
||||
userId: number;
|
||||
organisationId: string;
|
||||
term?: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
orderBy?: {
|
||||
column: keyof OrganisationMemberInvite;
|
||||
direction: 'asc' | 'desc';
|
||||
};
|
||||
}
|
||||
|
||||
export const findOrganisationMemberInvites = async ({
|
||||
userId,
|
||||
organisationId,
|
||||
term,
|
||||
page = 1,
|
||||
perPage = 10,
|
||||
orderBy,
|
||||
}: FindOrganisationMemberInvitesOptions) => {
|
||||
const orderByColumn = orderBy?.column ?? 'email';
|
||||
const orderByDirection = orderBy?.direction ?? 'desc';
|
||||
|
||||
// Check that the user belongs to the organisation they are trying to find invites in.
|
||||
const userOrganisation = await prisma.organisation.findUniqueOrThrow({
|
||||
where: {
|
||||
id: organisationId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
role: {
|
||||
in: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const termFilters: Prisma.OrganisationMemberInviteWhereInput | undefined = match(term)
|
||||
.with(P.string.minLength(1), () => ({
|
||||
email: {
|
||||
contains: term,
|
||||
mode: Prisma.QueryMode.insensitive,
|
||||
},
|
||||
}))
|
||||
.otherwise(() => undefined);
|
||||
|
||||
const whereClause: Prisma.OrganisationMemberInviteWhereInput = {
|
||||
...termFilters,
|
||||
organisationId: userOrganisation.id,
|
||||
};
|
||||
|
||||
const [data, count] = await Promise.all([
|
||||
prisma.organisationMemberInvite.findMany({
|
||||
where: whereClause,
|
||||
skip: Math.max(page - 1, 0) * perPage,
|
||||
take: perPage,
|
||||
orderBy: {
|
||||
[orderByColumn]: orderByDirection,
|
||||
},
|
||||
// Exclude token attribute.
|
||||
select: {
|
||||
id: true,
|
||||
organisationId: true,
|
||||
email: true,
|
||||
role: true,
|
||||
createdAt: true,
|
||||
},
|
||||
}),
|
||||
prisma.organisationMemberInvite.count({
|
||||
where: whereClause,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
data,
|
||||
count,
|
||||
currentPage: Math.max(page, 1),
|
||||
perPage,
|
||||
totalPages: Math.ceil(count / perPage),
|
||||
} satisfies FindResultSet<typeof data>;
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
import { P, match } from 'ts-pattern';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { OrganisationMember } from '@documenso/prisma/client';
|
||||
import { Prisma } from '@documenso/prisma/client';
|
||||
|
||||
import type { FindResultSet } from '../../types/find-result-set';
|
||||
|
||||
export interface FindOrganisationMembersOptions {
|
||||
userId: number;
|
||||
organisationId: string;
|
||||
term?: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
orderBy?: {
|
||||
column: keyof OrganisationMember | 'name';
|
||||
direction: 'asc' | 'desc';
|
||||
};
|
||||
}
|
||||
|
||||
export const findOrganisationMembers = async ({
|
||||
userId,
|
||||
organisationId,
|
||||
term,
|
||||
page = 1,
|
||||
perPage = 10,
|
||||
orderBy,
|
||||
}: FindOrganisationMembersOptions) => {
|
||||
const orderByColumn = orderBy?.column ?? 'name';
|
||||
const orderByDirection = orderBy?.direction ?? 'desc';
|
||||
|
||||
// Check that the user belongs to the organisation they are trying to find members in.
|
||||
const userOrganisation = await prisma.organisation.findUniqueOrThrow({
|
||||
where: {
|
||||
id: organisationId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
console.log(term);
|
||||
|
||||
const termFilters: Prisma.OrganisationMemberWhereInput | undefined = match(term)
|
||||
.with(P.string.minLength(1), () => ({
|
||||
user: {
|
||||
name: {
|
||||
contains: term,
|
||||
mode: Prisma.QueryMode.insensitive,
|
||||
},
|
||||
},
|
||||
}))
|
||||
.otherwise(() => undefined);
|
||||
|
||||
const whereClause: Prisma.OrganisationMemberWhereInput = {
|
||||
...termFilters,
|
||||
organisationId: userOrganisation.id,
|
||||
};
|
||||
|
||||
let orderByClause: Prisma.OrganisationMemberOrderByWithRelationInput = {
|
||||
[orderByColumn]: orderByDirection,
|
||||
};
|
||||
|
||||
// Name field is nested in the user so we have to handle it differently.
|
||||
if (orderByColumn === 'name') {
|
||||
orderByClause = {
|
||||
user: {
|
||||
name: orderByDirection,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const [data, count] = await Promise.all([
|
||||
prisma.organisationMember.findMany({
|
||||
where: whereClause,
|
||||
skip: Math.max(page - 1, 0) * perPage,
|
||||
take: perPage,
|
||||
orderBy: orderByClause,
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.organisationMember.count({
|
||||
where: whereClause,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
data,
|
||||
count,
|
||||
currentPage: Math.max(page, 1),
|
||||
perPage,
|
||||
totalPages: Math.ceil(count / perPage),
|
||||
} satisfies FindResultSet<typeof data>;
|
||||
};
|
||||
76
packages/lib/server-only/organisation/find-organisations.ts
Normal file
76
packages/lib/server-only/organisation/find-organisations.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { FindResultSet } from '@documenso/lib/types/find-result-set';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { Organisation } from '@documenso/prisma/client';
|
||||
import { Prisma } from '@documenso/prisma/client';
|
||||
|
||||
export interface FindOrganisationsOptions {
|
||||
userId: number;
|
||||
term?: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
orderBy?: {
|
||||
column: keyof Organisation;
|
||||
direction: 'asc' | 'desc';
|
||||
};
|
||||
}
|
||||
|
||||
export const findOrganisations = async ({
|
||||
userId,
|
||||
term,
|
||||
page = 1,
|
||||
perPage = 10,
|
||||
orderBy,
|
||||
}: FindOrganisationsOptions) => {
|
||||
const orderByColumn = orderBy?.column ?? 'name';
|
||||
const orderByDirection = orderBy?.direction ?? 'desc';
|
||||
|
||||
const whereClause: Prisma.OrganisationWhereInput = {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (term && term.length > 0) {
|
||||
whereClause.name = {
|
||||
contains: term,
|
||||
mode: Prisma.QueryMode.insensitive,
|
||||
};
|
||||
}
|
||||
|
||||
const [data, count] = await Promise.all([
|
||||
prisma.organisation.findMany({
|
||||
where: whereClause,
|
||||
skip: Math.max(page - 1, 0) * perPage,
|
||||
take: perPage,
|
||||
orderBy: {
|
||||
[orderByColumn]: orderByDirection,
|
||||
},
|
||||
include: {
|
||||
members: {
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.organisation.count({
|
||||
where: whereClause,
|
||||
}),
|
||||
]);
|
||||
|
||||
const maskedData = data.map((organisation) => ({
|
||||
...organisation,
|
||||
currentMember: organisation.members[0],
|
||||
members: undefined,
|
||||
}));
|
||||
|
||||
return {
|
||||
data: maskedData,
|
||||
count,
|
||||
currentPage: Math.max(page, 1),
|
||||
perPage,
|
||||
totalPages: Math.ceil(count / perPage),
|
||||
} satisfies FindResultSet<typeof maskedData>;
|
||||
};
|
||||
96
packages/lib/server-only/organisation/get-organisation.ts
Normal file
96
packages/lib/server-only/organisation/get-organisation.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { Prisma } from '@documenso/prisma/client';
|
||||
|
||||
export type GetOrganisationByIdOptions = {
|
||||
userId?: number;
|
||||
organisationId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get an organisation given an organisationId.
|
||||
*
|
||||
* Provide an optional userId to check that the user is a member of the organisation.
|
||||
*/
|
||||
export const getOrganisationById = async ({
|
||||
userId,
|
||||
organisationId,
|
||||
}: GetOrganisationByIdOptions) => {
|
||||
const whereFilter: Prisma.OrganisationWhereUniqueInput = {
|
||||
id: organisationId,
|
||||
};
|
||||
|
||||
if (userId !== undefined) {
|
||||
whereFilter['members'] = {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const result = await prisma.organisation.findUniqueOrThrow({
|
||||
where: whereFilter,
|
||||
include: {
|
||||
members: {
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
select: {
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { members, ...organisation } = result;
|
||||
|
||||
return {
|
||||
...organisation,
|
||||
currentMember: userId !== undefined ? members[0] : null,
|
||||
};
|
||||
};
|
||||
|
||||
export type GetOrganisationByUrlOptions = {
|
||||
userId: number;
|
||||
organisationUrl: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get an organisation given an organisation URL.
|
||||
*/
|
||||
export const getOrganisationByUrl = async ({
|
||||
userId,
|
||||
organisationUrl,
|
||||
}: GetOrganisationByUrlOptions) => {
|
||||
const whereFilter: Prisma.OrganisationWhereUniqueInput = {
|
||||
url: organisationUrl,
|
||||
};
|
||||
|
||||
if (userId !== undefined) {
|
||||
whereFilter['members'] = {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const result = await prisma.organisation.findUniqueOrThrow({
|
||||
where: whereFilter,
|
||||
include: {
|
||||
members: {
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
select: {
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { members, ...organisation } = result;
|
||||
|
||||
return {
|
||||
...organisation,
|
||||
currentMember: members[0],
|
||||
};
|
||||
};
|
||||
33
packages/lib/server-only/organisation/get-organisations.ts
Normal file
33
packages/lib/server-only/organisation/get-organisations.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type GetOrganisationsOptions = {
|
||||
userId: number;
|
||||
};
|
||||
export type GetOrganisationsResponse = Awaited<ReturnType<typeof getOrganisations>>;
|
||||
|
||||
export const getOrganisations = async ({ userId }: GetOrganisationsOptions) => {
|
||||
const organisations = await prisma.organisation.findMany({
|
||||
where: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
members: {
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
select: {
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return organisations.map(({ members, ...organisation }) => ({
|
||||
...organisation,
|
||||
currentMember: members[0],
|
||||
}));
|
||||
};
|
||||
55
packages/lib/server-only/organisation/leave-organisation.ts
Normal file
55
packages/lib/server-only/organisation/leave-organisation.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError } from '../../errors/app-error';
|
||||
|
||||
export type LeaveOrganisationOptions = {
|
||||
/**
|
||||
* The ID of the user who is leaving the organisation.
|
||||
*/
|
||||
userId: number;
|
||||
|
||||
/**
|
||||
* The ID of the organisation the user is leaving.
|
||||
*/
|
||||
organisationId: string;
|
||||
};
|
||||
|
||||
export const leaveOrganisation = async ({ userId, organisationId }: LeaveOrganisationOptions) => {
|
||||
const organisation = await prisma.organisation.findFirstOrThrow({
|
||||
where: {
|
||||
id: organisationId,
|
||||
ownerUserId: {
|
||||
not: userId,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
teams: {
|
||||
where: {
|
||||
ownerUserId: userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Todo: Orgs - Test this.
|
||||
if (organisation.teams.length > 0) {
|
||||
throw new AppError(
|
||||
'USER_HAS_TEAMS',
|
||||
'You cannot leave an organisation if you are the owner of a team in it.',
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.organisationMember.delete({
|
||||
where: {
|
||||
userId_organisationId: {
|
||||
userId,
|
||||
organisationId,
|
||||
},
|
||||
organisation: {
|
||||
ownerUserId: {
|
||||
not: userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { sendOrganisationMemberInviteEmail } from './create-organisation-member-invites';
|
||||
|
||||
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 organisation member invite.
|
||||
*/
|
||||
export const resendOrganisationMemberInvitation = async ({
|
||||
userId,
|
||||
userName,
|
||||
organisationId,
|
||||
invitationId,
|
||||
}: ResendOrganisationMemberInvitationOptions) => {
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
const organisation = await tx.organisation.findUniqueOrThrow({
|
||||
where: {
|
||||
id: organisationId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
role: {
|
||||
in: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!organisation) {
|
||||
throw new AppError(
|
||||
'OrganisationNotFound',
|
||||
'User is not a valid member of the organisation.',
|
||||
);
|
||||
}
|
||||
|
||||
const organisationMemberInvite = await tx.organisationMemberInvite.findUniqueOrThrow({
|
||||
where: {
|
||||
id: invitationId,
|
||||
organisationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!organisationMemberInvite) {
|
||||
throw new AppError('InviteNotFound', 'No invite exists for this user.');
|
||||
}
|
||||
|
||||
await sendOrganisationMemberInviteEmail({
|
||||
email: organisationMemberInvite.email,
|
||||
token: organisationMemberInvite.token,
|
||||
organisationName: organisation.name,
|
||||
organisationUrl: organisation.url,
|
||||
senderName: userName,
|
||||
});
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
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 { OrganisationMemberRole } from '@documenso/prisma/client';
|
||||
|
||||
export type UpdateOrganisationMemberOptions = {
|
||||
userId: number;
|
||||
organisationId: string;
|
||||
organisationMemberId: string;
|
||||
data: {
|
||||
role: OrganisationMemberRole;
|
||||
};
|
||||
};
|
||||
|
||||
export const updateOrganisationMember = async ({
|
||||
userId,
|
||||
organisationId,
|
||||
organisationMemberId,
|
||||
data,
|
||||
}: UpdateOrganisationMemberOptions) => {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Find the organisation and validate that the user is allowed to update members.
|
||||
const organisation = await tx.organisation.findFirstOrThrow({
|
||||
where: {
|
||||
id: organisationId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
role: {
|
||||
in: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
members: {
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const currentOrganisationMember = organisation.members.find(
|
||||
(member) => member.userId === userId,
|
||||
);
|
||||
const organisationMemberToUpdate = organisation.members.find(
|
||||
(member) => member.id === organisationMemberId,
|
||||
);
|
||||
|
||||
if (!organisationMemberToUpdate || !currentOrganisationMember) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, 'Organisation member does not exist');
|
||||
}
|
||||
|
||||
if (organisationMemberToUpdate.userId === organisation.ownerUserId) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Cannot update the owner');
|
||||
}
|
||||
|
||||
const isMemberToUpdateHigherRole = !isOrganisationRoleWithinUserHierarchy(
|
||||
currentOrganisationMember.role,
|
||||
organisationMemberToUpdate.role,
|
||||
);
|
||||
|
||||
if (isMemberToUpdateHigherRole) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Cannot update a member with a higher role');
|
||||
}
|
||||
|
||||
const isNewMemberRoleHigherThanCurrentRole = !isOrganisationRoleWithinUserHierarchy(
|
||||
currentOrganisationMember.role,
|
||||
data.role,
|
||||
);
|
||||
|
||||
if (isNewMemberRoleHigherThanCurrentRole) {
|
||||
throw new AppError(
|
||||
AppErrorCode.UNAUTHORIZED,
|
||||
'Cannot give a member a role higher than the user initating the update',
|
||||
);
|
||||
}
|
||||
|
||||
return await tx.organisationMember.update({
|
||||
where: {
|
||||
id: organisationMemberId,
|
||||
organisationId,
|
||||
userId: {
|
||||
not: organisation.ownerUserId,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
role: data.role,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
56
packages/lib/server-only/organisation/update-organisation.ts
Normal file
56
packages/lib/server-only/organisation/update-organisation.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { Prisma } from '@documenso/prisma/client';
|
||||
|
||||
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/organisations';
|
||||
|
||||
export type UpdateOrganisationOptions = {
|
||||
userId: number;
|
||||
organisationId: string;
|
||||
data: {
|
||||
name?: string;
|
||||
url?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const updateOrganisation = async ({
|
||||
userId,
|
||||
organisationId,
|
||||
data,
|
||||
}: UpdateOrganisationOptions) => {
|
||||
try {
|
||||
return await prisma.organisation.update({
|
||||
where: {
|
||||
id: organisationId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
role: {
|
||||
in: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
data: {
|
||||
url: data.url,
|
||||
name: data.name,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
if (!(err instanceof Prisma.PrismaClientKnownRequestError)) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const target = z.array(z.string()).safeParse(err.meta?.target);
|
||||
|
||||
if (err.code === 'P2002' && target.success && target.data.includes('url')) {
|
||||
throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Organisation URL already exists.');
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
@@ -3,7 +3,13 @@ import { hash } from '@node-rs/bcrypt';
|
||||
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
|
||||
import { updateSubscriptionItemQuantity } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { IdentityProvider, Prisma, TeamMemberInviteStatus } from '@documenso/prisma/client';
|
||||
import {
|
||||
IdentityProvider,
|
||||
InviteStatus,
|
||||
OrganisationMemberStatus,
|
||||
Prisma,
|
||||
TeamMemberInviteStatus,
|
||||
} from '@documenso/prisma/client';
|
||||
|
||||
import { IS_BILLING_ENABLED } from '../../constants/app';
|
||||
import { SALT_ROUNDS } from '../../constants/auth';
|
||||
@@ -57,76 +63,119 @@ export const createUser = async ({ name, email, password, signature, url }: Crea
|
||||
},
|
||||
});
|
||||
|
||||
const acceptedTeamInvites = await prisma.teamMemberInvite.findMany({
|
||||
where: {
|
||||
email: {
|
||||
equals: email,
|
||||
mode: Prisma.QueryMode.insensitive,
|
||||
const [acceptedTeamInvites, acceptedOrganisationInvites] = await Promise.all([
|
||||
prisma.teamMemberInvite.findMany({
|
||||
where: {
|
||||
email: {
|
||||
equals: email,
|
||||
mode: Prisma.QueryMode.insensitive,
|
||||
},
|
||||
status: TeamMemberInviteStatus.ACCEPTED,
|
||||
},
|
||||
status: TeamMemberInviteStatus.ACCEPTED,
|
||||
},
|
||||
});
|
||||
}),
|
||||
prisma.organisationMemberInvite.findMany({
|
||||
where: {
|
||||
email: {
|
||||
equals: email,
|
||||
mode: Prisma.QueryMode.insensitive,
|
||||
},
|
||||
status: InviteStatus.ACCEPTED,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
// For each team invite, add the user to the team and delete the team invite.
|
||||
// For each org/team invite, add the user to the org/team and delete the invite.
|
||||
// If an error occurs, reset the invitation to not accepted.
|
||||
await Promise.allSettled(
|
||||
acceptedTeamInvites.map(async (invite) =>
|
||||
prisma
|
||||
.$transaction(
|
||||
async (tx) => {
|
||||
await tx.teamMember.create({
|
||||
[
|
||||
acceptedTeamInvites.map(async (invite) =>
|
||||
prisma
|
||||
.$transaction(
|
||||
async (tx) => {
|
||||
await tx.teamMember.create({
|
||||
data: {
|
||||
teamId: invite.teamId,
|
||||
userId: user.id,
|
||||
role: invite.role,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.teamMemberInvite.delete({
|
||||
where: {
|
||||
id: invite.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!IS_BILLING_ENABLED()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const team = await tx.team.findFirstOrThrow({
|
||||
where: {
|
||||
id: invite.teamId,
|
||||
},
|
||||
include: {
|
||||
members: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
subscription: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (team.subscription) {
|
||||
await updateSubscriptionItemQuantity({
|
||||
priceId: team.subscription.priceId,
|
||||
subscriptionId: team.subscription.planId,
|
||||
quantity: team.members.length,
|
||||
});
|
||||
}
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.catch(async () => {
|
||||
await prisma.teamMemberInvite.update({
|
||||
where: {
|
||||
id: invite.id,
|
||||
},
|
||||
data: {
|
||||
teamId: invite.teamId,
|
||||
status: TeamMemberInviteStatus.PENDING,
|
||||
},
|
||||
});
|
||||
}),
|
||||
),
|
||||
acceptedOrganisationInvites.map(async (invite) =>
|
||||
prisma
|
||||
.$transaction(async (tx) => {
|
||||
await tx.organisationMember.create({
|
||||
data: {
|
||||
name: user.name ?? '',
|
||||
status: OrganisationMemberStatus.ACTIVE,
|
||||
organisationId: invite.organisationId,
|
||||
userId: user.id,
|
||||
role: invite.role,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.teamMemberInvite.delete({
|
||||
await tx.organisationMemberInvite.delete({
|
||||
where: {
|
||||
id: invite.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!IS_BILLING_ENABLED()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const team = await tx.team.findFirstOrThrow({
|
||||
})
|
||||
.catch(async () => {
|
||||
await prisma.organisationMemberInvite.update({
|
||||
where: {
|
||||
id: invite.teamId,
|
||||
id: invite.id,
|
||||
},
|
||||
include: {
|
||||
members: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
subscription: true,
|
||||
data: {
|
||||
status: InviteStatus.PENDING,
|
||||
},
|
||||
});
|
||||
|
||||
if (team.subscription) {
|
||||
await updateSubscriptionItemQuantity({
|
||||
priceId: team.subscription.priceId,
|
||||
subscriptionId: team.subscription.planId,
|
||||
quantity: team.members.length,
|
||||
});
|
||||
}
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.catch(async () => {
|
||||
await prisma.teamMemberInvite.update({
|
||||
where: {
|
||||
id: invite.id,
|
||||
},
|
||||
data: {
|
||||
status: TeamMemberInviteStatus.PENDING,
|
||||
},
|
||||
});
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
].flat(),
|
||||
);
|
||||
|
||||
// Update the user record with a new or existing Stripe customer record.
|
||||
|
||||
66
packages/lib/utils/organisations.ts
Normal file
66
packages/lib/utils/organisations.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { WEBAPP_BASE_URL } from '../constants/app';
|
||||
import type { ORGANISATION_MEMBER_ROLE_MAP } from '../constants/organisations';
|
||||
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '../constants/organisations';
|
||||
import { ORGANISATION_MEMBER_ROLE_HIERARCHY } from '../constants/organisations';
|
||||
|
||||
export const formatOrganisationUrl = (orgUrl: string, baseUrl?: string) => {
|
||||
const formattedBaseUrl = (baseUrl ?? WEBAPP_BASE_URL).replace(/https?:\/\//, '');
|
||||
|
||||
return `${formattedBaseUrl}/orgs/${orgUrl}`;
|
||||
};
|
||||
|
||||
// Todo: Maybe share with teams?
|
||||
export const formatDocumentsPathProto = ({
|
||||
orgUrl,
|
||||
teamUrl,
|
||||
}: {
|
||||
orgUrl?: string;
|
||||
teamUrl?: string;
|
||||
}) => {
|
||||
if (!orgUrl && !teamUrl) {
|
||||
throw new Error('Todo?');
|
||||
}
|
||||
|
||||
return teamUrl ? `/orgs/${orgUrl}/t/${teamUrl}/documents` : `/orgs/${orgUrl}/documents`;
|
||||
};
|
||||
|
||||
// Todo: Maybe share with teams?
|
||||
export const formatDocumentsPath = (orgUrl: string, teamUrl?: string) => {
|
||||
return teamUrl ? `/orgs/${orgUrl}/t/${teamUrl}/documents` : `/orgs/${orgUrl}/documents`;
|
||||
};
|
||||
|
||||
// Todo: Orgs - Common templates between teams?
|
||||
export const formatTemplatesPath = (orgUrl: string, teamUrl?: string) => {
|
||||
return `/orgs/${orgUrl}/t/${teamUrl}/templates`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines whether an organisation 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
|
||||
*
|
||||
* Todo: Orgs
|
||||
*/
|
||||
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);
|
||||
};
|
||||
308
packages/trpc/server/organisation-router/router.ts
Normal file
308
packages/trpc/server/organisation-router/router.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { createOrganisation } from '@documenso/lib/server-only/organisation/create-organisation';
|
||||
import { createOrganisationMemberInvites } from '@documenso/lib/server-only/organisation/create-organisation-member-invites';
|
||||
import { deleteOrganisationMemberInvitations } from '@documenso/lib/server-only/organisation/delete-organisation-member-invitations';
|
||||
import { deleteOrganisationMembers } from '@documenso/lib/server-only/organisation/delete-organisation-members';
|
||||
import { findOrganisationMemberInvites } from '@documenso/lib/server-only/organisation/find-organisation-member-invites';
|
||||
import { findOrganisationMembers } from '@documenso/lib/server-only/organisation/find-organisation-members';
|
||||
import { findOrganisations } from '@documenso/lib/server-only/organisation/find-organisations';
|
||||
import { leaveOrganisation } from '@documenso/lib/server-only/organisation/leave-organisation';
|
||||
import { resendOrganisationMemberInvitation } from '@documenso/lib/server-only/organisation/resend-organisation-member-invitation';
|
||||
import { updateOrganisation } from '@documenso/lib/server-only/organisation/update-organisation';
|
||||
import { updateOrganisationMember } from '@documenso/lib/server-only/organisation/update-organisation-member';
|
||||
|
||||
import { authenticatedProcedure, router } from '../trpc';
|
||||
import {
|
||||
ZCreateOrganisationMemberInvitesMutationSchema,
|
||||
ZCreateOrganisationMutationSchema,
|
||||
ZDeleteOrganisationMemberInvitationsMutationSchema,
|
||||
ZDeleteOrganisationMembersMutationSchema,
|
||||
ZFindOrganisationMemberInvitesQuerySchema,
|
||||
ZFindOrganisationMembersQuerySchema,
|
||||
ZFindOrganisationsQuerySchema,
|
||||
ZLeaveOrganisationMutationSchema,
|
||||
ZResendOrganisationMemberInvitationMutationSchema,
|
||||
ZUpdateOrganisationMemberMutationSchema,
|
||||
ZUpdateOrganisationMutationSchema,
|
||||
} from './schema';
|
||||
|
||||
export const organisationRouter = router({
|
||||
// acceptTeamInvitation: authenticatedProcedure
|
||||
// .input(ZAcceptTeamInvitationMutationSchema)
|
||||
// .mutation(async ({ input, ctx }) => {
|
||||
// try {
|
||||
// return await acceptTeamInvitation({
|
||||
// teamId: input.teamId,
|
||||
// userId: ctx.user.id,
|
||||
// });
|
||||
// } catch (err) {
|
||||
// console.error(err);
|
||||
|
||||
// throw AppError.parseErrorToTRPCError(err);
|
||||
// }
|
||||
// }),
|
||||
|
||||
// createBillingPortal: authenticatedProcedure
|
||||
// .input(ZCreateTeamBillingPortalMutationSchema)
|
||||
// .mutation(async ({ input, ctx }) => {
|
||||
// try {
|
||||
// return await createTeamBillingPortal({
|
||||
// userId: ctx.user.id,
|
||||
// ...input,
|
||||
// });
|
||||
// } catch (err) {
|
||||
// console.error(err);
|
||||
|
||||
// throw AppError.parseErrorToTRPCError(err);
|
||||
// }
|
||||
// }),
|
||||
|
||||
createOrganisation: authenticatedProcedure
|
||||
.input(ZCreateOrganisationMutationSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
return await createOrganisation({
|
||||
userId: ctx.user.id,
|
||||
...input,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
// Todo: Alert
|
||||
|
||||
throw AppError.parseErrorToTRPCError(err);
|
||||
}
|
||||
}),
|
||||
|
||||
createOrganisationMemberInvites: authenticatedProcedure
|
||||
.input(ZCreateOrganisationMemberInvitesMutationSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
return await createOrganisationMemberInvites({
|
||||
userId: ctx.user.id,
|
||||
userName: ctx.user.name ?? '',
|
||||
...input,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw AppError.parseErrorToTRPCError(err);
|
||||
}
|
||||
}),
|
||||
|
||||
deleteOrganisationMemberInvitations: authenticatedProcedure
|
||||
.input(ZDeleteOrganisationMemberInvitationsMutationSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
return await deleteOrganisationMemberInvitations({
|
||||
userId: ctx.user.id,
|
||||
...input,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw AppError.parseErrorToTRPCError(err);
|
||||
}
|
||||
}),
|
||||
|
||||
deleteOrganisationMembers: authenticatedProcedure
|
||||
.input(ZDeleteOrganisationMembersMutationSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
return await deleteOrganisationMembers({
|
||||
userId: ctx.user.id,
|
||||
...input,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw AppError.parseErrorToTRPCError(err);
|
||||
}
|
||||
}),
|
||||
|
||||
// findTeamInvoices: authenticatedProcedure
|
||||
// .input(ZFindTeamInvoicesQuerySchema)
|
||||
// .query(async ({ input, ctx }) => {
|
||||
// try {
|
||||
// return await findTeamInvoices({
|
||||
// userId: ctx.user.id,
|
||||
// ...input,
|
||||
// });
|
||||
// } catch (err) {
|
||||
// console.error(err);
|
||||
|
||||
// throw AppError.parseErrorToTRPCError(err);
|
||||
// }
|
||||
// }),
|
||||
|
||||
findOrganisationMemberInvites: authenticatedProcedure
|
||||
.input(ZFindOrganisationMemberInvitesQuerySchema)
|
||||
.query(async ({ input, ctx }) => {
|
||||
try {
|
||||
return await findOrganisationMemberInvites({
|
||||
userId: ctx.user.id,
|
||||
term: input.query,
|
||||
...input,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw AppError.parseErrorToTRPCError(err);
|
||||
}
|
||||
}),
|
||||
|
||||
findOrganisationMembers: authenticatedProcedure
|
||||
.input(ZFindOrganisationMembersQuerySchema)
|
||||
.query(async ({ input, ctx }) => {
|
||||
try {
|
||||
return await findOrganisationMembers({
|
||||
userId: ctx.user.id,
|
||||
term: input.query,
|
||||
...input,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw AppError.parseErrorToTRPCError(err);
|
||||
}
|
||||
}),
|
||||
|
||||
findOrganisations: authenticatedProcedure
|
||||
.input(ZFindOrganisationsQuerySchema)
|
||||
.query(async ({ input, ctx }) => {
|
||||
try {
|
||||
return await findOrganisations({
|
||||
userId: ctx.user.id,
|
||||
term: input.query,
|
||||
...input,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw AppError.parseErrorToTRPCError(err);
|
||||
}
|
||||
}),
|
||||
|
||||
// getTeam: authenticatedProcedure.input(ZGetTeamQuerySchema).query(async ({ input, ctx }) => {
|
||||
// try {
|
||||
// return await getTeamById({ teamId: input.teamId, userId: ctx.user.id });
|
||||
// } catch (err) {
|
||||
// console.error(err);
|
||||
|
||||
// throw AppError.parseErrorToTRPCError(err);
|
||||
// }
|
||||
// }),
|
||||
|
||||
// getTeamEmailByEmail: authenticatedProcedure.query(async ({ ctx }) => {
|
||||
// try {
|
||||
// return await getTeamEmailByEmail({ email: ctx.user.email });
|
||||
// } catch (err) {
|
||||
// console.error(err);
|
||||
|
||||
// throw AppError.parseErrorToTRPCError(err);
|
||||
// }
|
||||
// }),
|
||||
|
||||
// getTeamInvitations: authenticatedProcedure.query(async ({ ctx }) => {
|
||||
// try {
|
||||
// return await getTeamInvitations({ email: ctx.user.email });
|
||||
// } catch (err) {
|
||||
// console.error(err);
|
||||
|
||||
// throw AppError.parseErrorToTRPCError(err);
|
||||
// }
|
||||
// }),
|
||||
|
||||
// getTeamMembers: authenticatedProcedure
|
||||
// .input(ZGetTeamMembersQuerySchema)
|
||||
// .query(async ({ input, ctx }) => {
|
||||
// try {
|
||||
// return await getTeamMembers({ teamId: input.teamId, userId: ctx.user.id });
|
||||
// } catch (err) {
|
||||
// console.error(err);
|
||||
|
||||
// throw AppError.parseErrorToTRPCError(err);
|
||||
// }
|
||||
// }),
|
||||
|
||||
// getTeamPrices: authenticatedProcedure.query(async () => {
|
||||
// try {
|
||||
// return await getTeamPrices();
|
||||
// } catch (err) {
|
||||
// console.error(err);
|
||||
|
||||
// throw AppError.parseErrorToTRPCError(err);
|
||||
// }
|
||||
// }),
|
||||
|
||||
// getTeams: authenticatedProcedure.query(async ({ ctx }) => {
|
||||
// try {
|
||||
// return await getTeams({ userId: ctx.user.id });
|
||||
// } catch (err) {
|
||||
// console.error(err);
|
||||
|
||||
// throw AppError.parseErrorToTRPCError(err);
|
||||
// }
|
||||
// }),
|
||||
|
||||
leaveOrganisation: authenticatedProcedure
|
||||
.input(ZLeaveOrganisationMutationSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
return await leaveOrganisation({
|
||||
userId: ctx.user.id,
|
||||
...input,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw AppError.parseErrorToTRPCError(err);
|
||||
}
|
||||
}),
|
||||
|
||||
updateOrganisation: authenticatedProcedure
|
||||
.input(ZUpdateOrganisationMutationSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
return await updateOrganisation({
|
||||
userId: ctx.user.id,
|
||||
...input,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw AppError.parseErrorToTRPCError(err);
|
||||
}
|
||||
}),
|
||||
|
||||
updateOrganisationMember: authenticatedProcedure
|
||||
.input(ZUpdateOrganisationMemberMutationSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
return await updateOrganisationMember({
|
||||
userId: ctx.user.id,
|
||||
...input,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw AppError.parseErrorToTRPCError(err);
|
||||
}
|
||||
}),
|
||||
|
||||
resendOrganisationMemberInvitation: authenticatedProcedure
|
||||
.input(ZResendOrganisationMemberInvitationMutationSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
await resendOrganisationMemberInvitation({
|
||||
userId: ctx.user.id,
|
||||
userName: ctx.user.name ?? '',
|
||||
...input,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw AppError.parseErrorToTRPCError(err);
|
||||
}
|
||||
}),
|
||||
});
|
||||
171
packages/trpc/server/organisation-router/schema.ts
Normal file
171
packages/trpc/server/organisation-router/schema.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { PROTECTED_TEAM_URLS } from '@documenso/lib/constants/teams';
|
||||
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||
import { OrganisationMemberRole } from '@documenso/prisma/client';
|
||||
|
||||
/**
|
||||
* Restrict team URLs schema.
|
||||
*
|
||||
* Allowed characters:
|
||||
* - Alphanumeric
|
||||
* - Lowercase
|
||||
* - Dashes
|
||||
* - Underscores
|
||||
*
|
||||
* Conditions:
|
||||
* - 3-30 characters
|
||||
* - Cannot start and end with underscores or dashes.
|
||||
* - Cannot contain consecutive underscores or dashes.
|
||||
* - Cannot be a reserved URL in the PROTECTED_TEAM_URLS list
|
||||
*/
|
||||
// Todo: Orgs - Resuse from teams
|
||||
export const ZTeamUrlSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(3, { message: 'Team URL must be at least 3 characters long.' })
|
||||
.max(30, { message: 'Team URL must not exceed 30 characters.' })
|
||||
.toLowerCase()
|
||||
.regex(/^[a-z0-9].*[^_-]$/, 'Team URL cannot start or end with dashes or underscores.')
|
||||
.regex(/^(?!.*[-_]{2})/, 'Team URL cannot contain consecutive dashes or underscores.')
|
||||
.regex(
|
||||
/^[a-z0-9]+(?:[-_][a-z0-9]+)*$/,
|
||||
'Team URL can only contain letters, numbers, dashes and underscores.',
|
||||
)
|
||||
.refine((value) => !PROTECTED_TEAM_URLS.includes(value), {
|
||||
message: 'This URL is already in use.',
|
||||
});
|
||||
|
||||
export const ZTeamNameSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(3, { message: 'Team name must be at least 3 characters long.' })
|
||||
.max(30, { message: 'Team name must not exceed 30 characters.' });
|
||||
|
||||
export const ZAcceptTeamInvitationMutationSchema = z.object({
|
||||
organisationId: z.string(),
|
||||
});
|
||||
|
||||
export const ZCreateTeamBillingPortalMutationSchema = z.object({
|
||||
organisationId: z.string(),
|
||||
});
|
||||
|
||||
export const ZCreateOrganisationMutationSchema = z.object({
|
||||
organisationName: ZTeamNameSchema,
|
||||
organisationUrl: ZTeamUrlSchema,
|
||||
});
|
||||
|
||||
export const ZCreateTeamEmailVerificationMutationSchema = z.object({
|
||||
organisationId: z.string(),
|
||||
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
|
||||
email: z.string().trim().email().toLowerCase().min(1, 'Please enter a valid email.'),
|
||||
});
|
||||
|
||||
export const ZCreateOrganisationMemberInvitesMutationSchema = z.object({
|
||||
organisationId: z.string(),
|
||||
invitations: z.array(
|
||||
z.object({
|
||||
email: z.string().email().toLowerCase(),
|
||||
role: z.nativeEnum(OrganisationMemberRole),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export const ZCreateTeamPendingCheckoutMutationSchema = z.object({
|
||||
interval: z.union([z.literal('monthly'), z.literal('yearly')]),
|
||||
pendingTeamId: z.number(),
|
||||
});
|
||||
|
||||
export const ZDeleteTeamEmailMutationSchema = z.object({
|
||||
organisationId: z.string(),
|
||||
});
|
||||
|
||||
export const ZDeleteTeamEmailVerificationMutationSchema = z.object({
|
||||
organisationId: z.string(),
|
||||
});
|
||||
|
||||
export const ZDeleteOrganisationMembersMutationSchema = z.object({
|
||||
organisationId: z.string(),
|
||||
memberIds: z.array(z.string()),
|
||||
});
|
||||
|
||||
export const ZDeleteOrganisationMemberInvitationsMutationSchema = z.object({
|
||||
organisationId: z.string(),
|
||||
invitationIds: z.array(z.string()),
|
||||
});
|
||||
|
||||
export const ZDeleteTeamMutationSchema = z.object({
|
||||
organisationId: z.string(),
|
||||
});
|
||||
|
||||
export const ZDeleteTeamPendingMutationSchema = z.object({
|
||||
pendingTeamId: z.number(),
|
||||
});
|
||||
|
||||
export const ZDeleteTeamTransferRequestMutationSchema = z.object({
|
||||
organisationId: z.string(),
|
||||
});
|
||||
|
||||
export const ZFindTeamInvoicesQuerySchema = z.object({
|
||||
organisationId: z.string(),
|
||||
});
|
||||
|
||||
export const ZFindOrganisationMemberInvitesQuerySchema = ZBaseTableSearchParamsSchema.extend({
|
||||
organisationId: z.string(),
|
||||
});
|
||||
|
||||
export const ZFindOrganisationMembersQuerySchema = ZBaseTableSearchParamsSchema.extend({
|
||||
organisationId: z.string(),
|
||||
});
|
||||
|
||||
export const ZFindOrganisationsQuerySchema = ZBaseTableSearchParamsSchema;
|
||||
|
||||
export const ZGetTeamQuerySchema = z.object({
|
||||
organisationId: z.string(),
|
||||
});
|
||||
|
||||
export const ZGetTeamMembersQuerySchema = z.object({
|
||||
organisationId: z.string(),
|
||||
});
|
||||
|
||||
export const ZLeaveOrganisationMutationSchema = z.object({
|
||||
organisationId: z.string(),
|
||||
});
|
||||
|
||||
export const ZUpdateOrganisationMutationSchema = z.object({
|
||||
organisationId: z.string(),
|
||||
data: z.object({
|
||||
name: ZTeamNameSchema, // Todo: Orgs
|
||||
url: ZTeamUrlSchema,
|
||||
}),
|
||||
});
|
||||
|
||||
export const ZUpdateTeamEmailMutationSchema = z.object({
|
||||
organisationId: z.string(),
|
||||
data: z.object({
|
||||
name: z.string().trim().min(1),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ZUpdateOrganisationMemberMutationSchema = z.object({
|
||||
organisationId: z.string(),
|
||||
organisationMemberId: z.string(),
|
||||
data: z.object({
|
||||
role: z.nativeEnum(OrganisationMemberRole),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ZRequestTeamOwnerhsipTransferMutationSchema = z.object({
|
||||
organisationId: z.string(),
|
||||
newOwnerUserId: z.number(),
|
||||
clearPaymentMethods: z.boolean(),
|
||||
});
|
||||
|
||||
export const ZResendOrganisationMemberInvitationMutationSchema = z.object({
|
||||
organisationId: z.string(),
|
||||
invitationId: z.string(),
|
||||
});
|
||||
|
||||
export type TCreateOrganisationMemberInvitesMutationSchema = z.infer<
|
||||
typeof ZCreateOrganisationMemberInvitesMutationSchema
|
||||
>;
|
||||
@@ -4,6 +4,7 @@ import { authRouter } from './auth-router/router';
|
||||
import { cryptoRouter } from './crypto/router';
|
||||
import { documentRouter } from './document-router/router';
|
||||
import { fieldRouter } from './field-router/router';
|
||||
import { organisationRouter } from './organisation-router/router';
|
||||
import { profileRouter } from './profile-router/router';
|
||||
import { recipientRouter } from './recipient-router/router';
|
||||
import { shareLinkRouter } from './share-link-router/router';
|
||||
@@ -25,6 +26,7 @@ export const appRouter = router({
|
||||
shareLink: shareLinkRouter,
|
||||
apiToken: apiTokenRouter,
|
||||
singleplayer: singleplayerRouter,
|
||||
organisation: organisationRouter,
|
||||
team: teamRouter,
|
||||
template: templateRouter,
|
||||
webhook: webhookRouter,
|
||||
|
||||
Reference in New Issue
Block a user