feat: init

This commit is contained in:
David Nguyen
2024-03-31 17:07:45 +08:00
parent 56c550c9d2
commit 53158fd44f
43 changed files with 4257 additions and 55 deletions

View 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;

View 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[]>;

View File

@@ -79,6 +79,10 @@ export const PROTECTED_TEAM_URLS = [
'logout',
'maintenance',
'malware',
'org',
'orgs',
'organisation',
'organisations',
'newsletter',
'policy',
'privacy',

View File

@@ -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,
},
});
});
};

View File

@@ -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 }),
});
};

View 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;
}
};

View File

@@ -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,
},
});
});
};

View File

@@ -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,
},
},
});
});
};

View File

@@ -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>;
};

View File

@@ -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>;
};

View 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>;
};

View 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],
};
};

View 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],
}));
};

View 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,
},
},
},
});
};

View File

@@ -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 },
);
};

View File

@@ -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,
},
});
});
};

View 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;
}
};

View File

@@ -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.

View 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);
};

View 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);
}
}),
});

View 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
>;

View File

@@ -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,