feat: wip

This commit is contained in:
David Nguyen
2023-12-27 13:04:24 +11:00
parent f7cf33c61b
commit 9d626473c8
140 changed files with 9604 additions and 536 deletions

View File

@ -71,6 +71,7 @@ export const getServerLimits = async ({ email }: GetServerLimitsOptions) => {
const documents = await prisma.document.count({
where: {
userId: user.id,
teamId: null,
createdAt: {
gte: DateTime.utc().startOf('month').toJSDate(),
},

View File

@ -1,17 +1,21 @@
'use server';
import type Stripe from 'stripe';
import { stripe } from '@documenso/lib/server-only/stripe';
export type GetCheckoutSessionOptions = {
customerId: string;
priceId: string;
returnUrl: string;
subscriptionMetadata?: Stripe.Metadata;
};
export const getCheckoutSession = async ({
customerId,
priceId,
returnUrl,
subscriptionMetadata,
}: GetCheckoutSessionOptions) => {
'use server';
@ -26,6 +30,9 @@ export const getCheckoutSession = async ({
],
success_url: `${returnUrl}?success=true`,
cancel_url: `${returnUrl}?canceled=true`,
subscription_data: {
metadata: subscriptionMetadata,
},
});
return session.url;

View File

@ -78,6 +78,14 @@ export const getStripeCustomerByUser = async (user: User) => {
};
};
export const getStripeCustomerIdByUser = async (user: User) => {
if (user.customerId !== null) {
return user.customerId;
}
return await getStripeCustomerByUser(user).then((session) => session.stripeCustomer.id);
};
const syncStripeCustomerSubscriptions = async (userId: number, stripeCustomerId: string) => {
const stripeSubscriptions = await stripe.subscriptions.list({
customer: stripeCustomerId,

View File

@ -0,0 +1,23 @@
import { stripe } from '@documenso/lib/server-only/stripe';
export type GetTeamInvoicesOptions = {
teamId: number;
};
export const getTeamInvoices = async ({ teamId }: GetTeamInvoicesOptions) => {
const teamSubscriptions = await stripe.subscriptions.search({
limit: 100,
query: `metadata["teamId"]:"${teamId}"`,
});
const subscriptionIds = teamSubscriptions.data.map((subscription) => subscription.id);
if (subscriptionIds.length === 0) {
return null;
}
return await stripe.invoices.search({
query: subscriptionIds.map((id) => `subscription:"${id}"`).join(' OR '),
limit: 100,
});
};

View File

@ -0,0 +1,96 @@
import type Stripe from 'stripe';
import { stripe } from '@documenso/lib/server-only/stripe';
import {
getTeamSeatPriceId,
isSomeSubscriptionsActiveAndCommunityPlan,
} from '@documenso/lib/utils/billing';
import { prisma } from '@documenso/prisma';
import type { Subscription, Team, User } from '@documenso/prisma/client';
import { getStripeCustomerByUser } from './get-customer';
type TransferStripeSubscriptionOptions = {
user: User & { Subscription: Subscription[] };
team: Team;
};
/**
* Transfer the Stripe Team seats subscription from one user to another.
*
* Will create a new subscription for the new owner and cancel the old one.
*
* Returns the new subscription, null if no subscription is needed (for community plan).
*/
export const transferTeamSubscription = async ({
user,
team,
}: TransferStripeSubscriptionOptions) => {
const teamSeatPriceId = getTeamSeatPriceId();
const { stripeCustomer } = await getStripeCustomerByUser(user);
const newOwnerHasCommunityPlan = isSomeSubscriptionsActiveAndCommunityPlan(user.Subscription);
const currentTeamSubscriptionId = team.subscriptionId;
let oldSubscription: Stripe.Subscription | null = null;
let newSubscription: Stripe.Subscription | null = null;
if (currentTeamSubscriptionId) {
oldSubscription = await stripe.subscriptions.retrieve(currentTeamSubscriptionId);
}
const numberOfSeats = await prisma.teamMember.count({
where: {
teamId: team.id,
},
});
if (!newOwnerHasCommunityPlan) {
let stripeCreateSubscriptionPayload: Stripe.SubscriptionCreateParams = {
customer: stripeCustomer.id,
items: [
{
price: teamSeatPriceId,
quantity: numberOfSeats,
},
],
metadata: {
teamId: team.id.toString(),
},
};
// If no payment method is attached to the new owner Stripe customer account, send an
// invoice instead.
if (!stripeCustomer.invoice_settings.default_payment_method) {
stripeCreateSubscriptionPayload = {
...stripeCreateSubscriptionPayload,
collection_method: 'send_invoice',
days_until_due: 7,
};
}
newSubscription = await stripe.subscriptions.create(stripeCreateSubscriptionPayload);
}
if (oldSubscription) {
try {
// Set the quantity to 0 so we can refund/charge the old Stripe customer the prorated amount.
await stripe.subscriptions.update(oldSubscription.id, {
items: oldSubscription.items.data.map((item) => ({
id: item.id,
quantity: 0,
})),
});
await stripe.subscriptions.cancel(oldSubscription.id, {
invoice_now: true,
prorate: false,
});
} catch (e) {
// Do not error out since we can't easily undo the transfer.
// Todo: Teams - Alert us.
}
}
return newSubscription;
};

View File

@ -0,0 +1,28 @@
import { stripe } from '@documenso/lib/server-only/stripe';
export type UpdateSubscriptionItemQuantityOptions = {
subscriptionId: string;
quantity?: number;
priceId: string;
};
export const updateSubscriptionItemQuantity = async ({
subscriptionId,
quantity,
priceId,
}: UpdateSubscriptionItemQuantityOptions) => {
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const items = subscription.items.data.filter((item) => item.price.id === priceId);
if (items.length === 0) {
return;
}
await stripe.subscriptions.update(subscriptionId, {
items: items.map((item) => ({
id: item.id,
quantity,
})),
});
};

View File

@ -5,6 +5,7 @@ import { match } from 'ts-pattern';
import type { Stripe } from '@documenso/lib/server-only/stripe';
import { stripe } from '@documenso/lib/server-only/stripe';
import { createTeamFromPendingTeam } from '@documenso/lib/server-only/team/create-team';
import { getFlag } from '@documenso/lib/universal/get-feature-flag';
import { prisma } from '@documenso/prisma';
@ -110,6 +111,12 @@ export const stripeWebhookHandler = async (
await onSubscriptionUpdated({ userId, subscription });
if (
subscription.items.data[0].price.id === process.env.NEXT_PUBLIC_STRIPE_TEAM_SEAT_PRICE_ID
) {
await handleTeamSeatCheckout({ subscription });
}
return res.status(200).json({
success: true,
message: 'Webhook received',
@ -282,3 +289,21 @@ export const stripeWebhookHandler = async (
});
}
};
export type HandleTeamSeatCheckoutOptions = {
subscription: Stripe.Subscription;
};
const handleTeamSeatCheckout = async ({ subscription }: HandleTeamSeatCheckoutOptions) => {
if (subscription.metadata?.pendingTeamId === undefined) {
return;
}
const pendingTeamId = Number(subscription.metadata.pendingTeamId);
if (Number.isNaN(pendingTeamId)) {
throw new Error('Invalid pending team ID');
}
await createTeamFromPendingTeam({ pendingTeamId, subscriptionId: subscription.id });
};

View File

@ -13,12 +13,19 @@ export const onSubscriptionUpdated = async ({
userId,
subscription,
}: OnSubscriptionUpdatedOptions) => {
await prisma.subscription.upsert(mapStripeSubscriptionToPrismaUpsertAction(userId, subscription));
};
export const mapStripeSubscriptionToPrismaUpsertAction = (
userId: number,
subscription: Stripe.Subscription,
) => {
const status = match(subscription.status)
.with('active', () => SubscriptionStatus.ACTIVE)
.with('past_due', () => SubscriptionStatus.PAST_DUE)
.otherwise(() => SubscriptionStatus.INACTIVE);
await prisma.subscription.upsert({
return {
where: {
planId: subscription.id,
},
@ -37,5 +44,5 @@ export const onSubscriptionUpdated = async ({
periodEnd: new Date(subscription.current_period_end * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
});
};
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@ -0,0 +1,17 @@
import { Img } from '../components';
export interface TemplateImageProps {
assetBaseUrl: string;
className?: string;
staticAsset: string;
}
export const TemplateImage = ({ assetBaseUrl, className, staticAsset }: TemplateImageProps) => {
const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString();
};
return <Img className={className} src={getAssetUrl(`/static/${staticAsset}`)} />;
};
export default TemplateImage;

View File

@ -7,7 +7,7 @@ import { TemplateFooter } from '../template-components/template-footer';
export const ConfirmEmailTemplate = ({
confirmationLink,
assetBaseUrl,
assetBaseUrl = 'http://localhost:3002',
}: TemplateConfirmationEmailProps) => {
const previewText = `Please confirm your email address`;
@ -55,3 +55,5 @@ export const ConfirmEmailTemplate = ({
</Html>
);
};
export default ConfirmEmailTemplate;

View File

@ -0,0 +1,124 @@
import config from '@documenso/tailwind-config';
import {
Body,
Button,
Container,
Head,
Hr,
Html,
Link,
Preview,
Section,
Tailwind,
Text,
} from '../components';
import { TemplateFooter } from '../template-components/template-footer';
import TemplateImage from '../template-components/template-image';
export type ConfirmTeamEmailProps = {
assetBaseUrl: string;
baseUrl: string;
teamName: string;
teamUrl: string;
token: string;
};
export const ConfirmTeamEmailTemplate = ({
assetBaseUrl = 'http://localhost:3002',
baseUrl = 'https://documenso.com',
teamName = 'Team Name',
teamUrl = 'demo',
token = '',
}: ConfirmTeamEmailProps) => {
const previewText = `Accept team email request for ${teamName} 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">
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 px-2 pt-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="mail-open.png"
/>
</Section>
<Section className="p-2 text-slate-500">
<Text className="text-center text-lg font-medium text-black">
Verify your team email address
</Text>
<Text className="text-center text-base">
<span className="font-bold">{teamName}</span> has requested to use your email
address for their team on Documenso.
</Text>
<div className="mx-auto mt-6 w-fit rounded-lg bg-gray-50 px-4 py-2 text-base font-medium text-slate-600">
{baseUrl.replace('https://', '')}/t/{teamUrl}
</div>
<Section className="mt-6">
<Text className="my-0 text-sm">
By accepting this request, you will be granting <strong>{teamName}</strong>{' '}
access to:
</Text>
<ul className="mb-0 mt-2">
<li className="text-sm">View all documents sent to this email address</li>
<li className="mt-1 text-sm">
Allow document recipients to reply directly to this email address
</li>
</ul>
<Text className="mt-2 text-sm">
You can revoke access at any time in your team settings on Documenso{' '}
<Link href={`${baseUrl}/settings/teams`}>here.</Link>
</Text>
</Section>
<Section className="mb-6 mt-8 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}/team/verify/email/${token}`}
>
Accept
</Button>
</Section>
</Section>
<Text className="text-center text-xs text-slate-500">Link expires in 1 hour.</Text>
</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 ConfirmTeamEmailTemplate;

View File

@ -0,0 +1,107 @@
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 TeamInviteEmailProps = {
assetBaseUrl: string;
baseUrl: string;
senderName: string;
teamName: string;
teamUrl: string;
token: string;
};
export const TeamInviteEmailTemplate = ({
assetBaseUrl = 'http://localhost:3002',
baseUrl = 'https://documenso.com',
senderName = 'John Doe',
teamName = 'Team Name',
teamUrl = 'demo',
token = '',
}: TeamInviteEmailProps) => {
const previewText = `Accept invitation to join a team 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 {teamName} on Documenso
</Text>
<Text className="my-1 text-center text-base">
You have been invited to join the following team
</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">
{baseUrl.replace('https://', '')}/t/{teamUrl}
</div>
<Text className="my-1 text-center text-base">
by <span className="text-slate-900">{senderName}</span>
</Text>
<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}/team/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 TeamInviteEmailTemplate;

View File

@ -0,0 +1,111 @@
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 TeamTransferRequestTemplateProps = {
assetBaseUrl: string;
baseUrl: string;
senderName: string;
teamName: string;
teamUrl: string;
token: string;
};
export const TeamTransferRequestTemplate = ({
assetBaseUrl = 'http://localhost:3002',
baseUrl = 'https://documenso.com',
senderName = 'John Doe',
teamName = 'Team Name',
teamUrl = 'demo',
token = '',
}: TeamTransferRequestTemplateProps) => {
const previewText = 'Accept team transfer request 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 px-2 pt-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">
{teamName} ownership transfer request
</Text>
<Text className="my-1 text-center text-base">
<span className="font-bold">{senderName}</span> has requested that you take
ownership of the following team
</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">
{baseUrl.replace('https://', '')}/t/{teamUrl}
</div>
<Text className="text-center text-sm">
By accepting this request, you will take responsibility for any billing items
associated with this team.
</Text>
<Section className="mb-6 mt-6 text-center">
<Button
className="bg-documenso-500 ml-2 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
href={`${baseUrl}/team/verify/transfer/${token}`}
>
Accept
</Button>
</Section>
</Section>
<Text className="text-center text-xs">Link expires in 1 hour.</Text>
</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 TeamTransferRequestTemplate;

View File

@ -1,8 +1,13 @@
export const IS_APP_MARKETING = process.env.NEXT_PUBLIC_PROJECT === 'marketing';
export const IS_APP_WEB = process.env.NEXT_PUBLIC_PROJECT === 'web';
export const IS_BILLING_ENABLED = process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true';
export const APP_FOLDER = IS_APP_MARKETING ? 'marketing' : 'web';
export const APP_BASE_URL = IS_APP_WEB
? process.env.NEXT_PUBLIC_WEBAPP_URL
: process.env.NEXT_PUBLIC_MARKETING_URL;
export const WEBAPP_BASE_URL = process.env.NEXT_PUBLIC_WEBAPP_URL ?? 'http://localhost:3000';
export const MARKETING_BASE_URL = process.env.NEXT_PUBLIC_MARKETING_URL ?? 'http://localhost:3001';

View File

@ -0,0 +1,34 @@
import { TeamMemberRole } from '@documenso/prisma/client';
export const TEAM_MEMBER_ROLE_MAP: Record<keyof typeof TeamMemberRole, string> = {
ADMIN: 'Admin',
MANAGER: 'Manager',
MEMBER: 'Member',
};
export const TEAM_MEMBER_ROLE_PERMISSIONS_MAP = {
/**
* Includes updating team name, url, logo, emails.
*
* Todo: Teams - Clean this up, merge etc.
*/
MANAGE_TEAM: [TeamMemberRole.ADMIN, TeamMemberRole.MANAGER],
DELETE_INVITATIONS: [TeamMemberRole.ADMIN, TeamMemberRole.MANAGER],
DELETE_TEAM_MEMBERS: [TeamMemberRole.ADMIN, TeamMemberRole.MANAGER],
DELETE_TEAM_TRANSFER_REQUEST: [TeamMemberRole.ADMIN],
UPDATE_TEAM_MEMBERS: [TeamMemberRole.ADMIN, TeamMemberRole.MANAGER],
} satisfies Record<string, TeamMemberRole[]>;
/**
* Determines whether a team member can execute a given action.
*
* @param action The action the user is trying to execute.
* @param role The current role of the user.
* @returns Whether the user can execute the action.
*/
export const canExecuteTeamAction = (
action: keyof typeof TEAM_MEMBER_ROLE_PERMISSIONS_MAP,
role: keyof typeof TEAM_MEMBER_ROLE_MAP,
) => {
return TEAM_MEMBER_ROLE_PERMISSIONS_MAP[action].some((i) => i === role);
};

View File

@ -0,0 +1,144 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { TRPCClientError } from '@documenso/trpc/client';
/**
* Generic application error codes.
*/
export enum AppErrorCode {
'ALREADY_EXISTS' = 'AlreadyExists',
'EXPIRED_CODE' = 'ExpiredCode',
'INVALID_BODY' = 'InvalidBody',
'INVALID_REQUEST' = 'InvalidRequest',
'NOT_FOUND' = 'NotFound',
'NOT_SETUP' = 'NotSetup',
'UNAUTHORIZED' = 'Unauthorized',
'UNKNOWN_ERROR' = 'UnknownError',
'RETRY_EXCEPTION' = 'RetryException',
'SCHEMA_FAILED' = 'SchemaFailed',
'TOO_MANY_REQUESTS' = 'TooManyRequests',
}
const genericErrorCodeToTrpcErrorCodeMap: Record<string, TRPCError['code']> = {
[AppErrorCode.ALREADY_EXISTS]: 'BAD_REQUEST',
[AppErrorCode.EXPIRED_CODE]: 'BAD_REQUEST',
[AppErrorCode.INVALID_BODY]: 'BAD_REQUEST',
[AppErrorCode.INVALID_REQUEST]: 'BAD_REQUEST',
[AppErrorCode.NOT_FOUND]: 'NOT_FOUND',
[AppErrorCode.NOT_SETUP]: 'BAD_REQUEST',
[AppErrorCode.UNAUTHORIZED]: 'UNAUTHORIZED',
[AppErrorCode.UNKNOWN_ERROR]: 'INTERNAL_SERVER_ERROR',
[AppErrorCode.RETRY_EXCEPTION]: 'INTERNAL_SERVER_ERROR',
[AppErrorCode.SCHEMA_FAILED]: 'INTERNAL_SERVER_ERROR',
[AppErrorCode.TOO_MANY_REQUESTS]: 'TOO_MANY_REQUESTS',
};
export const ZAppErrorJsonSchema = z.object({
code: z.string(),
message: z.string().optional(),
userMessage: z.string().optional(),
});
export type TAppErrorJsonSchema = z.infer<typeof ZAppErrorJsonSchema>;
export class AppError extends Error {
/**
* The error code.
*/
code: string;
/**
* An error message which can be displayed to the user.
*/
userMessage?: string;
/**
* Create a new AppError.
*
* @param errorCode A string representing the error code.
* @param message An internal error message.
* @param userMessage A error message which can be displayed to the user.
*/
public constructor(errorCode: string, message?: string, userMessage?: string) {
super(message || errorCode);
this.code = errorCode;
this.userMessage = userMessage;
}
/**
* Parse an unknown value into an AppError.
*
* @param error An unknown type.
*/
static parseError(error: unknown): AppError {
if (error instanceof AppError) {
return error;
}
// Handle TRPC errors.
if (error instanceof TRPCClientError) {
const parsedJsonError = AppError.parseFromJSONString(error.message);
return parsedJsonError || new AppError('UnknownError', error.message);
}
// Handle completely unknown errors.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const { code, message, userMessage } = error as {
code: unknown;
message: unknown;
status: unknown;
userMessage: unknown;
};
const validCode: string | null = typeof code === 'string' ? code : AppErrorCode.UNKNOWN_ERROR;
const validMessage: string | undefined = typeof message === 'string' ? message : undefined;
const validUserMessage: string | undefined =
typeof userMessage === 'string' ? userMessage : undefined;
return new AppError(validCode, validMessage, validUserMessage);
}
static parseErrorToTRPCError(error: unknown): TRPCError {
const appError = AppError.parseError(error);
return new TRPCError({
code: genericErrorCodeToTrpcErrorCodeMap[appError.code] || 'BAD_REQUEST',
message: AppError.toJSONString(appError),
});
}
/**
* Convert an AppError into a JSON object which represents the error.
*
* @param appError The AppError to convert to JSON.
* @returns A JSON object representing the AppError.
*/
static toJSON({ code, message, userMessage }: AppError): TAppErrorJsonSchema {
return {
code,
message,
userMessage,
};
}
/**
* Convert an AppError into a JSON string containing the relevant information.
*
* @param appError The AppError to stringify.
* @returns A JSON string representing the AppError.
*/
static toJSONString(appError: AppError): string {
return JSON.stringify(AppError.toJSON(appError));
}
static parseFromJSONString(jsonString: string): AppError | null {
const parsed = ZAppErrorJsonSchema.safeParse(JSON.parse(jsonString));
if (!parsed.success) {
return null;
}
return new AppError(parsed.data.code, parsed.data.message, parsed.data.userMessage);
}
}

View File

@ -5,15 +5,37 @@ import { prisma } from '@documenso/prisma';
export type CreateDocumentOptions = {
title: string;
userId: number;
teamId?: number;
documentDataId: string;
};
export const createDocument = async ({ userId, title, documentDataId }: CreateDocumentOptions) => {
return await prisma.document.create({
data: {
title,
documentDataId,
userId,
},
export const createDocument = async ({
userId,
title,
documentDataId,
teamId,
}: CreateDocumentOptions) => {
return await prisma.$transaction(async (tx) => {
if (teamId !== undefined) {
await tx.team.findFirstOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
});
}
return await tx.document.create({
data: {
title,
documentDataId,
userId,
teamId,
},
});
});
};

View File

@ -2,7 +2,7 @@ import { DateTime } from 'luxon';
import { P, match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
import type { Document, Prisma } from '@documenso/prisma/client';
import type { Document, Prisma, Team, TeamEmail, User } from '@documenso/prisma/client';
import { SigningStatus } from '@documenso/prisma/client';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
@ -10,6 +10,7 @@ import type { FindResultSet } from '../../types/find-result-set';
export type FindDocumentsOptions = {
userId: number;
teamId?: number;
term?: string;
status?: ExtendedDocumentStatus;
page?: number;
@ -19,21 +20,51 @@ export type FindDocumentsOptions = {
direction: 'asc' | 'desc';
};
period?: '' | '7d' | '14d' | '30d';
senderIds?: number[];
};
export const findDocuments = async ({
userId,
teamId,
term,
status = ExtendedDocumentStatus.ALL,
page = 1,
perPage = 10,
orderBy,
period,
senderIds,
}: FindDocumentsOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
// Todo: Teams - deletedAt
const { user, team } = await prisma.$transaction(async (tx) => {
const user = await tx.user.findFirstOrThrow({
where: {
id: userId,
},
});
let team = null;
if (teamId !== undefined) {
team = await tx.team.findFirstOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
include: {
teamEmail: true,
},
});
}
return {
user,
team,
};
});
const orderByColumn = orderBy?.column ?? 'createdAt';
@ -50,11 +81,79 @@ export const findDocuments = async ({
})
.otherwise(() => undefined);
const filters = match<ExtendedDocumentStatus, Prisma.DocumentWhereInput>(status)
const filters = team ? findTeamDocumentsFilter(status, team) : findDocumentsFilter(status, user);
if (filters === null) {
return {
data: [],
count: 0,
currentPage: 1,
perPage,
totalPages: 0,
};
}
const whereClause: Prisma.DocumentWhereInput = {
...termFilters,
...filters,
};
if (period) {
const daysAgo = parseInt(period.replace(/d$/, ''), 10);
const startOfPeriod = DateTime.now().minus({ days: daysAgo }).startOf('day');
whereClause.createdAt = {
gte: startOfPeriod.toJSDate(),
};
}
if (senderIds && senderIds.length > 0) {
whereClause.userId = {
in: senderIds,
};
}
const [data, count] = await Promise.all([
prisma.document.findMany({
where: whereClause,
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
orderBy: {
[orderByColumn]: orderByDirection,
},
include: {
User: {
select: {
id: true,
name: true,
email: true,
},
},
Recipient: true,
},
}),
prisma.document.count({
where: whereClause,
}),
]);
return {
data,
count,
currentPage: Math.max(page, 1),
perPage,
totalPages: Math.ceil(count / perPage),
} satisfies FindResultSet<typeof data>;
};
const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => {
return match<ExtendedDocumentStatus, Prisma.DocumentWhereInput>(status)
.with(ExtendedDocumentStatus.ALL, () => ({
OR: [
{
userId,
userId: user.id,
teamId: null,
deletedAt: null,
},
{
@ -89,14 +188,16 @@ export const findDocuments = async ({
deletedAt: null,
}))
.with(ExtendedDocumentStatus.DRAFT, () => ({
userId,
userId: user.id,
teamId: null,
status: ExtendedDocumentStatus.DRAFT,
deletedAt: null,
}))
.with(ExtendedDocumentStatus.PENDING, () => ({
OR: [
{
userId,
userId: user.id,
teamId: null,
status: ExtendedDocumentStatus.PENDING,
deletedAt: null,
},
@ -115,7 +216,8 @@ export const findDocuments = async ({
.with(ExtendedDocumentStatus.COMPLETED, () => ({
OR: [
{
userId,
userId: user.id,
teamId: null,
status: ExtendedDocumentStatus.COMPLETED,
deletedAt: null,
},
@ -130,54 +232,154 @@ export const findDocuments = async ({
],
}))
.exhaustive();
};
const whereClause = {
...termFilters,
...filters,
};
/**
* Create a Prisma filter for the Document schema to find documents for a team.
*
* Status All:
* - Documents that belong to the team
* - Documents that have been sent by the team email
* - Non draft documents that have been sent to the team email
*
* Status Inbox:
* - Non draft documents that have been sent to the team email that have not been signed
*
* Status Draft:
* - Documents that belong to the team that are draft
*
* Status Pending:
* - Documents that belong to the team that are pending
* - Documents that have been sent to the team email that is pending to be signed
* - Documents that have been sent by the team email that is pending to be signed
*
* Status Completed:
* - Documents that belong to the team that are completed
* - Documents that have been sent to the team email that have been signed
* - Documents that have been sent by the team email that have been signed
*
* @param status The status of the documents to find.
* @param team The team to find the documents for.
* @returns A filter which can be applied to the Prisma Document schema.
*/
const findTeamDocumentsFilter = (
status: ExtendedDocumentStatus,
team: Team & { teamEmail: TeamEmail | null },
) => {
const teamEmail = team.teamEmail?.email ?? null;
if (period) {
const daysAgo = parseInt(period.replace(/d$/, ''), 10);
return match<ExtendedDocumentStatus, Prisma.DocumentWhereInput | null>(status)
.with(ExtendedDocumentStatus.ALL, () => {
const filter: Prisma.DocumentWhereInput = {
// Filter to display all documents that belong to the team.
OR: [
{
teamId: team.id,
},
],
};
const startOfPeriod = DateTime.now().minus({ days: daysAgo }).startOf('day');
if (teamEmail && filter.OR) {
// Filter to display all documents received by the team email that are not draft.
filter.OR.push({
status: {
not: ExtendedDocumentStatus.DRAFT,
},
Recipient: {
some: {
email: teamEmail,
},
},
});
whereClause.createdAt = {
gte: startOfPeriod.toJSDate(),
};
}
// Filter to display all documents that have been sent by the team email.
filter.OR.push({
User: {
email: teamEmail,
},
});
}
const [data, count] = await Promise.all([
prisma.document.findMany({
where: whereClause,
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
orderBy: {
[orderByColumn]: orderByDirection,
},
include: {
User: {
select: {
id: true,
name: true,
email: true,
return filter;
})
.with(ExtendedDocumentStatus.INBOX, () => {
// Return a filter that will return nothing.
// Todo: Teams - Should be a better way to do this.
if (!teamEmail) {
return null;
}
return {
status: {
not: ExtendedDocumentStatus.DRAFT,
},
Recipient: {
some: {
email: teamEmail,
signingStatus: SigningStatus.NOT_SIGNED,
},
},
Recipient: true,
},
}),
prisma.document.count({
where: {
...termFilters,
...filters,
},
}),
]);
};
})
.with(ExtendedDocumentStatus.DRAFT, () => ({
teamId: team.id,
status: ExtendedDocumentStatus.DRAFT,
}))
.with(ExtendedDocumentStatus.PENDING, () => {
const filter: Prisma.DocumentWhereInput = {
OR: [
{
teamId: team.id,
status: ExtendedDocumentStatus.PENDING,
},
],
};
return {
data,
count,
currentPage: Math.max(page, 1),
perPage,
totalPages: Math.ceil(count / perPage),
} satisfies FindResultSet<typeof data>;
if (teamEmail && filter.OR) {
// Filter to display all documents received by the team email that are pending.
filter.OR.push({
status: ExtendedDocumentStatus.PENDING,
Recipient: {
some: {
email: teamEmail,
signingStatus: SigningStatus.SIGNED,
},
},
});
// Filter to display all documents that have been sent by the team email that are pending.
filter.OR.push({
status: ExtendedDocumentStatus.PENDING,
User: {
email: teamEmail,
},
});
}
return filter;
})
.with(ExtendedDocumentStatus.COMPLETED, () => {
const filter: Prisma.DocumentWhereInput = {
OR: [
{
teamId: team.id,
status: ExtendedDocumentStatus.COMPLETED,
},
],
};
if (teamEmail && filter.OR) {
filter.OR.push({
status: ExtendedDocumentStatus.COMPLETED,
Recipient: {
some: {
email: teamEmail,
},
},
});
}
return filter;
})
.exhaustive();
};

View File

@ -10,6 +10,24 @@ export const getDocumentById = async ({ id, userId }: GetDocumentByIdOptions) =>
where: {
id,
userId,
OR: [
{
team: {
is: null,
},
},
{
team: {
is: {
members: {
some: {
userId,
},
},
},
},
},
],
},
include: {
documentData: true,

View File

@ -1,15 +1,63 @@
import { prisma } from '@documenso/prisma';
import type { User } from '@documenso/prisma/client';
import type { Prisma } from '@documenso/prisma/client';
import { SigningStatus } from '@documenso/prisma/client';
import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
export type GetStatsInput = {
user: User;
type TeamStatsOptions = {
teamId: number;
teamEmail?: string;
senderIds?: number[];
};
export const getStats = async ({ user }: GetStatsInput) => {
const [ownerCounts, notSignedCounts, hasSignedCounts] = await Promise.all([
export type GetStatsInput = {
user: User;
team?: TeamStatsOptions;
};
export const getStats = async ({ user, ...options }: GetStatsInput) => {
const [ownerCounts, notSignedCounts, hasSignedCounts] = await (options.team
? getTeamCounts({ team: options.team })
: getCounts(user));
const stats: Record<ExtendedDocumentStatus, number> = {
[ExtendedDocumentStatus.DRAFT]: 0,
[ExtendedDocumentStatus.PENDING]: 0,
[ExtendedDocumentStatus.COMPLETED]: 0,
[ExtendedDocumentStatus.INBOX]: 0,
[ExtendedDocumentStatus.ALL]: 0,
};
ownerCounts.forEach((stat) => {
stats[stat.status] = stat._count._all;
});
notSignedCounts.forEach((stat) => {
stats[ExtendedDocumentStatus.INBOX] += stat._count._all;
});
hasSignedCounts.forEach((stat) => {
if (stat.status === ExtendedDocumentStatus.COMPLETED) {
stats[ExtendedDocumentStatus.COMPLETED] += stat._count._all;
}
if (stat.status === ExtendedDocumentStatus.PENDING) {
stats[ExtendedDocumentStatus.PENDING] += stat._count._all;
}
});
Object.keys(stats).forEach((key) => {
if (key !== ExtendedDocumentStatus.ALL && isExtendedDocumentStatus(key)) {
stats[ExtendedDocumentStatus.ALL] += stats[key];
}
});
return stats;
};
const getCounts = async (user: User) => {
return Promise.all([
prisma.document.groupBy({
by: ['status'],
_count: {
@ -17,6 +65,7 @@ export const getStats = async ({ user }: GetStatsInput) => {
},
where: {
userId: user.id,
teamId: null,
deletedAt: null,
},
}),
@ -66,38 +115,110 @@ export const getStats = async ({ user }: GetStatsInput) => {
},
}),
]);
};
const stats: Record<ExtendedDocumentStatus, number> = {
[ExtendedDocumentStatus.DRAFT]: 0,
[ExtendedDocumentStatus.PENDING]: 0,
[ExtendedDocumentStatus.COMPLETED]: 0,
[ExtendedDocumentStatus.INBOX]: 0,
[ExtendedDocumentStatus.ALL]: 0,
const getTeamCounts = async ({ team }: { team: TeamStatsOptions }) => {
const { teamId, teamEmail } = team;
const senderIds = team.senderIds ?? [];
const userIdWhereClause: Prisma.DocumentWhereInput['userId'] =
senderIds.length > 0
? {
in: senderIds,
}
: undefined;
let ownerCountsWhereInput: Prisma.DocumentWhereInput = {
userId: userIdWhereClause,
teamId,
deletedAt: null,
};
ownerCounts.forEach((stat) => {
stats[stat.status] = stat._count._all;
});
if (teamEmail && senderIds.length === 0) {
ownerCountsWhereInput = {
OR: [
{
teamId,
},
{
User: {
email: teamEmail,
},
},
],
};
}
notSignedCounts.forEach((stat) => {
stats[ExtendedDocumentStatus.INBOX] += stat._count._all;
});
if (teamEmail && senderIds.length > 0) {
ownerCountsWhereInput = {
userId: userIdWhereClause,
OR: [
{
teamId,
},
{
User: {
email: teamEmail,
},
},
],
deletedAt: null,
};
}
hasSignedCounts.forEach((stat) => {
if (stat.status === ExtendedDocumentStatus.COMPLETED) {
stats[ExtendedDocumentStatus.COMPLETED] += stat._count._all;
}
let notSignedCountsGroupByArgs = null;
if (stat.status === ExtendedDocumentStatus.PENDING) {
stats[ExtendedDocumentStatus.PENDING] += stat._count._all;
}
});
if (teamEmail) {
notSignedCountsGroupByArgs = {
by: ['status'],
_count: {
_all: true,
},
where: {
userId: userIdWhereClause,
status: ExtendedDocumentStatus.PENDING,
Recipient: {
some: {
email: teamEmail,
signingStatus: SigningStatus.NOT_SIGNED,
},
},
deletedAt: null,
},
} satisfies Prisma.DocumentGroupByArgs;
}
Object.keys(stats).forEach((key) => {
if (key !== ExtendedDocumentStatus.ALL && isExtendedDocumentStatus(key)) {
stats[ExtendedDocumentStatus.ALL] += stats[key];
}
});
let hasSignedCountsGroupByArgs = null;
return stats;
if (teamEmail) {
hasSignedCountsGroupByArgs = {
by: ['status'],
_count: {
_all: true,
},
where: {
status: ExtendedDocumentStatus.PENDING,
Recipient: {
some: {
email: teamEmail,
signingStatus: SigningStatus.SIGNED,
},
},
deletedAt: null,
},
} satisfies Prisma.DocumentGroupByArgs;
}
return Promise.all([
prisma.document.groupBy({
by: ['status'],
_count: {
_all: true,
},
where: ownerCountsWhereInput,
}),
notSignedCountsGroupByArgs ? prisma.document.groupBy(notSignedCountsGroupByArgs) : [],
hasSignedCountsGroupByArgs ? prisma.document.groupBy(hasSignedCountsGroupByArgs) : [],
]);
};

View File

@ -26,6 +26,24 @@ export const setFieldsForDocument = async ({
where: {
id: documentId,
userId,
OR: [
{
team: {
is: null,
},
},
{
team: {
is: {
members: {
some: {
userId,
},
},
},
},
},
],
},
});

View File

@ -22,6 +22,24 @@ export const setRecipientsForDocument = async ({
where: {
id: documentId,
userId,
OR: [
{
team: {
is: null,
},
},
{
team: {
is: {
members: {
some: {
userId,
},
},
},
},
},
],
},
});

View File

@ -0,0 +1,60 @@
import { updateSubscriptionItemQuantity } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
import { prisma } from '@documenso/prisma';
import { IS_BILLING_ENABLED } from '../../constants/app';
import { getTeamSeatPriceId } from '../../utils/billing';
export type AcceptTeamInvitationOptions = {
userId: number;
teamId: number;
};
export const acceptTeamInvitation = async ({ userId, teamId }: AcceptTeamInvitationOptions) => {
await prisma.$transaction(async (tx) => {
const user = await tx.user.findFirstOrThrow({
where: {
id: userId,
},
});
const teamMemberInvite = await tx.teamMemberInvite.findFirstOrThrow({
where: {
teamId,
email: user.email,
},
include: {
team: true,
},
});
const { team } = teamMemberInvite;
await tx.teamMember.create({
data: {
teamId: teamMemberInvite.teamId,
userId: user.id,
role: teamMemberInvite.role,
},
});
await tx.teamMemberInvite.delete({
where: {
id: teamMemberInvite.id,
},
});
if (IS_BILLING_ENABLED && team.subscriptionId) {
const numberOfSeats = await tx.teamMember.count({
where: {
teamId: teamMemberInvite.teamId,
},
});
await updateSubscriptionItemQuantity({
priceId: getTeamSeatPriceId(),
subscriptionId: team.subscriptionId,
quantity: numberOfSeats,
});
}
});
};

View File

@ -0,0 +1,133 @@
import { createElement } from 'react';
import { z } from 'zod';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { ConfirmTeamEmailTemplate } from '@documenso/email/templates/confirm-team-email';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { Prisma } from '@documenso/prisma/client';
import { WEBAPP_BASE_URL } from '../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
import { createTokenVerification } from '../../utils/token-verification';
export type AddTeamEmailVerificationOptions = {
userId: number;
teamId: number;
data: {
email: string;
name: string;
};
};
export const addTeamEmailVerification = async ({
userId,
teamId,
data,
}: AddTeamEmailVerificationOptions) => {
try {
await prisma.$transaction(async (tx) => {
const team = await tx.team.findFirstOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
role: {
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
},
},
},
},
include: {
teamEmail: true,
emailVerification: true,
},
});
if (team.teamEmail || team.emailVerification) {
throw new AppError(
AppErrorCode.INVALID_REQUEST,
'Team already has an email or existing email verification.',
);
}
const existingTeamEmail = await tx.teamEmail.findFirst({
where: {
email: data.email,
},
});
if (existingTeamEmail) {
throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Email already taken by another team.');
}
const { token, expiresAt } = createTokenVerification({ hours: 1 });
await tx.teamEmailVerification.create({
data: {
token,
expiresAt,
email: data.email,
name: data.name,
teamId,
},
});
await sendTeamEmailVerificationEmail(data.email, token, team.name, team.url);
});
} 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('email')) {
throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Email already taken by another team.');
}
throw err;
}
};
/**
* Send an email to a user asking them to accept a team email request.
*
* @param email The email address to use for the team.
* @param token The token used to authenticate that the user has granted access.
* @param teamName The name of the team the user is being invited to.
* @param teamUrl The url of the team the user is being invited to.
*/
export const sendTeamEmailVerificationEmail = async (
email: string,
token: string,
teamName: string,
teamUrl: string,
) => {
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
const template = createElement(ConfirmTeamEmailTemplate, {
assetBaseUrl,
baseUrl: WEBAPP_BASE_URL,
teamName,
teamUrl,
token,
});
await mailer.sendMail({
to: email,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: `A request to use your email has been initiated by ${teamName} on Documenso`,
html: render(template),
text: render(template, { plainText: true }),
});
};

View File

@ -0,0 +1,51 @@
import { getCheckoutSession } from '@documenso/ee/server-only/stripe/get-checkout-session';
import { getStripeCustomerIdByUser } from '@documenso/ee/server-only/stripe/get-customer';
import { prisma } from '@documenso/prisma';
import { WEBAPP_BASE_URL } from '../../constants/app';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { getTeamSeatPriceId } from '../../utils/billing';
export type CreateTeamPendingCheckoutSession = {
userId: number;
pendingTeamId: number;
};
export const createTeamPendingCheckoutSession = async ({
userId,
pendingTeamId,
}: CreateTeamPendingCheckoutSession) => {
const teamPendingCreation = await prisma.teamPending.findFirstOrThrow({
where: {
id: pendingTeamId,
ownerUserId: userId,
},
include: {
owner: true,
},
});
const stripeCustomerId = await getStripeCustomerIdByUser(teamPendingCreation.owner);
try {
const stripeCheckoutSession = await getCheckoutSession({
customerId: stripeCustomerId,
priceId: getTeamSeatPriceId(),
returnUrl: `${WEBAPP_BASE_URL}/settings/teams`,
subscriptionMetadata: {
pendingTeamId: pendingTeamId.toString(),
},
});
if (!stripeCheckoutSession) {
throw new AppError(AppErrorCode.UNKNOWN_ERROR);
}
return stripeCheckoutSession;
} catch (e) {
console.error(e);
// Absorb all the errors incase stripe throws something sensitive.
throw new AppError(AppErrorCode.UNKNOWN_ERROR, 'Something went wrong.');
}
};

View File

@ -0,0 +1,157 @@
import { createElement } from 'react';
import { nanoid } from 'nanoid';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import type { TeamInviteEmailProps } from '@documenso/email/templates/team-invite';
import { TeamInviteEmailTemplate } from '@documenso/email/templates/team-invite';
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import { prisma } from '@documenso/prisma';
import { TeamMemberInviteStatus } from '@documenso/prisma/client';
import type { TCreateTeamMemberInvitesMutationSchema } from '@documenso/trpc/server/team-router/schema';
import { WEBAPP_BASE_URL } from '../../constants/app';
import { AppError } from '../../errors/app-error';
import { getTeamById } from './get-teams';
export type CreateTeamMemberInvitesOptions = {
userId: number;
userName: string;
teamId: number;
invitations: TCreateTeamMemberInvitesMutationSchema['invitations'];
};
/**
* Invite team members via email to join a team.
*/
export const createTeamMemberInvites = async ({
userId,
userName,
teamId,
invitations,
}: CreateTeamMemberInvitesOptions) => {
const [team, currentTeamMemberEmails, currentTeamMemberInviteEmails] = await Promise.all([
getTeamById({ userId, teamId }),
getTeamMemberEmails(teamId),
getTeamInvites(teamId),
]);
const usersToInvite = invitations.filter((invitation) => {
// Filter out users that are already members of the team.
if (currentTeamMemberEmails.includes(invitation.email)) {
return false;
}
// Filter out users that have already been invited to the team.
if (currentTeamMemberInviteEmails.includes(invitation.email)) {
return false;
}
return true;
});
const teamMemberInvites = usersToInvite.map(({ email, role }) => ({
email,
teamId,
role,
status: TeamMemberInviteStatus.PENDING,
token: nanoid(32),
}));
await prisma.teamMemberInvite.createMany({
data: teamMemberInvites,
});
const sendEmailResult = await Promise.allSettled(
teamMemberInvites.map(async ({ email, token }) =>
sendTeamMemberInviteEmail({
email,
token,
teamName: team.name,
teamUrl: team.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}/${teamMemberInvites.length} users.`,
);
}
};
type SendTeamMemberInviteEmailOptions = Omit<TeamInviteEmailProps, 'baseUrl' | 'assetBaseUrl'> & {
email: string;
};
/**
* Send an email to a user inviting them to join a team.
*/
export const sendTeamMemberInviteEmail = async ({
email,
...emailTemplateOptions
}: SendTeamMemberInviteEmailOptions) => {
const template = createElement(TeamInviteEmailTemplate, {
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.teamName} on Documenso`,
html: render(template),
text: render(template, { plainText: true }),
});
};
/**
* Returns a list of emails of the team members for a given team.
*
* @param teamId The ID of the team.
* @returns All team member emails for a given team.
*/
const getTeamMemberEmails = async (teamId: number) => {
const teamMembers = await prisma.teamMember.findMany({
where: {
teamId,
},
include: {
user: true,
},
});
return teamMembers.map((teamMember) => teamMember.user.email);
};
/**
* Returns a list of emails that have been invited to join a team.
*
* This list will not include users who have accepted and created an account.
*
* @param teamId The ID of the team.
* @returns All the emails of users that have been invited to join a team.
*/
const getTeamInvites = async (teamId: number) => {
const teamMemberInvites = await prisma.teamMemberInvite.findMany({
where: {
teamId,
},
});
return teamMemberInvites.map((teamMemberInvite) => teamMemberInvite.email);
};

View File

@ -0,0 +1,200 @@
import { z } from 'zod';
import { getCheckoutSession } from '@documenso/ee/server-only/stripe/get-checkout-session';
import { getStripeCustomerIdByUser } from '@documenso/ee/server-only/stripe/get-customer';
import {
getTeamSeatPriceId,
isSomeSubscriptionsActiveAndCommunityPlan,
} from '@documenso/lib/utils/billing';
import { prisma } from '@documenso/prisma';
import { Prisma, TeamMemberRole } from '@documenso/prisma/client';
import { IS_BILLING_ENABLED, WEBAPP_BASE_URL } from '../../constants/app';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { stripe } from '../stripe';
export type CreateTeamOptions = {
/**
* ID of the user creating the Team.
*/
userId: number;
/**
* Name of the team to display.
*/
name: string;
/**
* Unique URL of the team.
*
* Used as the URL path, example: https://documenso.com/t/{teamUrl}/settings
*/
teamUrl: string;
};
export type CreateTeamResponse =
| {
paymentRequired: false;
}
| {
paymentRequired: true;
checkoutUrl: string;
};
/**
* Create a team or pending team depending on the user's subscription or application's billing settings.
*/
export const createTeam = async ({
name,
userId,
teamUrl,
}: CreateTeamOptions): Promise<CreateTeamResponse> => {
const user = await prisma.user.findUniqueOrThrow({
where: {
id: userId,
},
include: {
Subscription: true,
},
});
const isUserSubscriptionValidForTeams = isSomeSubscriptionsActiveAndCommunityPlan(
user.Subscription,
);
const isPaymentRequired = IS_BILLING_ENABLED && !isUserSubscriptionValidForTeams;
try {
// Create the team directly if no payment is required.
if (!isPaymentRequired) {
await prisma.team.create({
data: {
name,
url: teamUrl,
ownerUserId: user.id,
members: {
create: [
{
userId,
role: TeamMemberRole.ADMIN,
},
],
},
},
});
return {
paymentRequired: false,
};
}
// Create a pending team if payment is required.
return await prisma.$transaction(async (tx) => {
const existingTeamWithUrl = await tx.team.findUnique({
where: {
url: teamUrl,
},
});
if (existingTeamWithUrl) {
throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Team URL already exists.');
}
const pendingTeam = await tx.teamPending.create({
data: {
name,
url: teamUrl,
ownerUserId: user.id,
},
});
const stripeCustomerId = await getStripeCustomerIdByUser(user);
const stripeCheckoutSession = await getCheckoutSession({
customerId: stripeCustomerId,
priceId: getTeamSeatPriceId(),
returnUrl: `${WEBAPP_BASE_URL}/settings/teams`,
subscriptionMetadata: {
pendingTeamId: pendingTeam.id.toString(),
},
});
if (!stripeCheckoutSession) {
throw new AppError('Unable to create checkout session');
}
return {
paymentRequired: true,
checkoutUrl: stripeCheckoutSession,
};
});
} 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, 'Team URL already exists.');
}
throw err;
}
};
export type CreateTeamFromPendingTeamOptions = {
pendingTeamId: number;
subscriptionId: string;
};
export const createTeamFromPendingTeam = async ({
pendingTeamId,
subscriptionId,
}: CreateTeamFromPendingTeamOptions) => {
await prisma.$transaction(async (tx) => {
const pendingTeam = await tx.teamPending.findUniqueOrThrow({
where: {
id: pendingTeamId,
},
});
await tx.teamPending.delete({
where: {
id: pendingTeamId,
},
});
const team = await tx.team.create({
data: {
name: pendingTeam.name,
url: pendingTeam.url,
ownerUserId: pendingTeam.ownerUserId,
subscriptionId,
members: {
create: [
{
userId: pendingTeam.ownerUserId,
role: TeamMemberRole.ADMIN,
},
],
},
},
});
// Attach the team ID to the subscription metadata so we can keep track of it if the team changes ownership.
await stripe.subscriptions
.update(subscriptionId, {
metadata: {
teamId: team.id.toString(),
},
})
.catch((e) => {
console.error(e);
// Non-critical error, but we want to log it so we can rectify it.
// Todo: Teams - Send alert.
});
});
};

View File

@ -0,0 +1,34 @@
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
import { prisma } from '@documenso/prisma';
export type DeleteTeamEmailVerificationOptions = {
userId: number;
teamId: number;
};
export const deleteTeamEmailVerification = async ({
userId,
teamId,
}: DeleteTeamEmailVerificationOptions) => {
await prisma.$transaction(async (tx) => {
await tx.team.findFirstOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
role: {
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
},
},
},
},
});
await tx.teamEmailVerification.delete({
where: {
teamId,
},
});
});
};

View File

@ -0,0 +1,46 @@
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
import { prisma } from '@documenso/prisma';
export type DeleteTeamEmailOptions = {
userId: number;
userEmail: string;
teamId: number;
};
/**
* Delete a team email.
*
* The user must either be part of the team with the required permissions, or the owner of the email.
*/
export const deleteTeamEmail = async ({ userId, userEmail, teamId }: DeleteTeamEmailOptions) => {
await prisma.$transaction(async (tx) => {
await tx.team.findFirstOrThrow({
where: {
id: teamId,
OR: [
{
teamEmail: {
email: userEmail,
},
},
{
members: {
some: {
userId,
role: {
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
},
},
},
},
],
},
});
await tx.teamEmail.delete({
where: {
teamId,
},
});
});
};

View File

@ -0,0 +1,47 @@
import { prisma } from '@documenso/prisma';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams';
export type DeleteTeamMemberInvitationsOptions = {
/**
* The ID of the user who is initiating this action.
*/
userId: number;
/**
* The ID of the team to remove members from.
*/
teamId: number;
/**
* The IDs of the team members to remove.
*/
invitationIds: number[];
};
export const deleteTeamMemberInvitations = async ({
userId,
teamId,
invitationIds,
}: DeleteTeamMemberInvitationsOptions) => {
await prisma.$transaction(async (tx) => {
await tx.teamMember.findFirstOrThrow({
where: {
userId,
teamId,
role: {
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['DELETE_INVITATIONS'],
},
},
});
await tx.teamMemberInvite.deleteMany({
where: {
id: {
in: invitationIds,
},
teamId,
},
});
});
};

View File

@ -0,0 +1,73 @@
import { updateSubscriptionItemQuantity } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
import { prisma } from '@documenso/prisma';
import { IS_BILLING_ENABLED } from '../../constants/app';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams';
import { getTeamSeatPriceId } from '../../utils/billing';
export type DeleteTeamMembersOptions = {
/**
* The ID of the user who is initiating this action.
*/
userId: number;
/**
* The ID of the team to remove members from.
*/
teamId: number;
/**
* The IDs of the team members to remove.
*/
teamMemberIds: number[];
};
export const deleteTeamMembers = async ({
userId,
teamId,
teamMemberIds,
}: DeleteTeamMembersOptions) => {
await prisma.$transaction(async (tx) => {
// Find the team and validate that the user is allowed to remove members.
const team = await tx.team.findFirstOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
role: {
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['DELETE_TEAM_MEMBERS'],
},
},
},
},
});
// Remove the team members.
await tx.teamMember.deleteMany({
where: {
id: {
in: teamMemberIds,
},
teamId,
userId: {
not: team.ownerUserId,
},
},
});
if (IS_BILLING_ENABLED && team.subscriptionId) {
const numberOfSeats = await tx.teamMember.count({
where: {
teamId,
},
});
await updateSubscriptionItemQuantity({
priceId: getTeamSeatPriceId(),
subscriptionId: team.subscriptionId,
quantity: numberOfSeats,
});
}
});
};

View File

@ -0,0 +1,15 @@
import { prisma } from '@documenso/prisma';
export type DeleteTeamPendingOptions = {
userId: number;
pendingTeamId: number;
};
export const deleteTeamPending = async ({ userId, pendingTeamId }: DeleteTeamPendingOptions) => {
await prisma.teamPending.delete({
where: {
id: pendingTeamId,
ownerUserId: userId,
},
});
};

View File

@ -0,0 +1,42 @@
import { prisma } from '@documenso/prisma';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams';
export type DeleteTeamTransferRequestOptions = {
/**
* The ID of the user deleting the transfer.
*/
userId: number;
/**
* The ID of the team whose team transfer invitation should be deleted.
*/
teamId: number;
};
export const deleteTeamTransferRequest = async ({
userId,
teamId,
}: DeleteTeamTransferRequestOptions) => {
await prisma.$transaction(async (tx) => {
await tx.team.findFirstOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
role: {
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['DELETE_TEAM_TRANSFER_REQUEST'],
},
},
},
},
});
await tx.teamTransferVerification.delete({
where: {
teamId,
},
});
});
};

View File

@ -0,0 +1,39 @@
import { prisma } from '@documenso/prisma';
import { AppError } from '../../errors/app-error';
import { stripe } from '../stripe';
export type DeleteTeamOptions = {
userId: number;
teamId: number;
};
export const deleteTeam = async ({ userId, teamId }: DeleteTeamOptions) => {
await prisma.$transaction(async (tx) => {
const team = await tx.team.findFirstOrThrow({
where: {
id: teamId,
ownerUserId: userId,
},
});
if (team.subscriptionId !== null) {
await stripe.subscriptions
.cancel(team.subscriptionId, {
prorate: true,
invoice_now: true,
})
.catch((err) => {
console.error(err);
throw AppError.parseError(err);
});
}
await tx.team.delete({
where: {
id: teamId,
ownerUserId: userId,
},
});
});
};

View File

@ -0,0 +1,48 @@
import { getTeamInvoices } from '@documenso/ee/server-only/stripe/get-team-invoices';
import { prisma } from '@documenso/prisma';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams';
export interface FindTeamInvoicesOptions {
userId: number;
teamId: number;
}
export const findTeamInvoices = async ({ userId, teamId }: FindTeamInvoicesOptions) => {
await prisma.team.findUniqueOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
role: {
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
},
},
},
},
});
const results = await getTeamInvoices({ teamId });
if (!results) {
return null;
}
return {
...results,
data: results.data.map((invoice) => ({
invoicePdf: invoice.invoice_pdf,
hostedInvoicePdf: invoice.hosted_invoice_url,
status: invoice.status,
subtotal: invoice.subtotal,
total: invoice.total,
amountPaid: invoice.amount_paid,
amountDue: invoice.amount_due,
created: invoice.created,
paid: invoice.paid,
quantity: invoice.lines.data[0].quantity ?? 0,
currency: invoice.currency,
})),
};
};

View File

@ -0,0 +1,88 @@
import { P, match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
import type { TeamMemberInvite } from '@documenso/prisma/client';
import { Prisma } from '@documenso/prisma/client';
import type { FindResultSet } from '../../types/find-result-set';
export interface FindTeamMemberInvitesOptions {
userId: number;
teamId: number;
term?: string;
page?: number;
perPage?: number;
orderBy?: {
column: keyof TeamMemberInvite;
direction: 'asc' | 'desc';
};
}
export const findTeamMemberInvites = async ({
userId,
teamId,
term,
page = 1,
perPage = 10,
orderBy,
}: FindTeamMemberInvitesOptions) => {
const orderByColumn = orderBy?.column ?? 'email';
const orderByDirection = orderBy?.direction ?? 'desc';
// Check that the user belongs to the team they are trying to find invites in.
const userTeam = await prisma.team.findUniqueOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
},
},
// Todo: Teams - Should only certain roles be able to find members?
},
});
const termFilters: Prisma.TeamMemberInviteWhereInput | undefined = match(term)
.with(P.string.minLength(1), () => ({
email: {
contains: term,
mode: Prisma.QueryMode.insensitive,
},
}))
.otherwise(() => undefined);
const whereClause: Prisma.TeamMemberInviteWhereInput = {
...termFilters,
teamId: userTeam.id,
};
const [data, count] = await Promise.all([
prisma.teamMemberInvite.findMany({
where: whereClause,
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
orderBy: {
[orderByColumn]: orderByDirection,
},
// Exclude token attribute.
select: {
id: true,
teamId: true,
email: true,
role: true,
createdAt: true,
},
}),
prisma.teamMemberInvite.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,99 @@
import { P, match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
import { Prisma, TeamMember } from '@documenso/prisma/client';
import { FindResultSet } from '../../types/find-result-set';
export interface FindTeamMembersOptions {
userId: number;
teamId: number;
term?: string;
page?: number;
perPage?: number;
orderBy?: {
column: keyof TeamMember | 'name';
direction: 'asc' | 'desc';
};
}
export const findTeamMembers = async ({
userId,
teamId,
term,
page = 1,
perPage = 10,
orderBy,
}: FindTeamMembersOptions) => {
const orderByColumn = orderBy?.column ?? 'name';
const orderByDirection = orderBy?.direction ?? 'desc';
// Check that the user belongs to the team they are trying to find members in.
const userTeam = await prisma.team.findUniqueOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
},
},
// Todo: Teams - Should only certain roles be able to find members?
},
});
const termFilters: Prisma.TeamMemberWhereInput | undefined = match(term)
.with(P.string.minLength(1), () => ({
user: {
name: {
contains: term,
mode: Prisma.QueryMode.insensitive,
},
},
}))
.otherwise(() => undefined);
const whereClause: Prisma.TeamMemberWhereInput = {
...termFilters,
teamId: userTeam.id,
};
let orderByClause: Prisma.TeamMemberOrderByWithRelationInput = {
[orderByColumn]: orderByDirection,
};
if (orderByColumn === 'name') {
orderByClause = {
user: {
name: orderByDirection,
},
};
}
const [data, count] = await Promise.all([
prisma.teamMember.findMany({
where: whereClause,
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
orderBy: orderByClause,
include: {
user: {
select: {
name: true,
email: true,
},
},
},
}),
prisma.teamMember.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,58 @@
import { prisma } from '@documenso/prisma';
import type { Team } from '@documenso/prisma/client';
import { Prisma } from '@documenso/prisma/client';
export interface FindTeamsPendingOptions {
userId: number;
term?: string;
page?: number;
perPage?: number;
orderBy?: {
column: keyof Team;
direction: 'asc' | 'desc';
};
}
export const findTeamsPending = async ({
userId,
term,
page = 1,
perPage = 10,
orderBy,
}: FindTeamsPendingOptions) => {
const orderByColumn = orderBy?.column ?? 'name';
const orderByDirection = orderBy?.direction ?? 'desc';
const whereClause: Prisma.TeamPendingWhereInput = {
ownerUserId: userId,
};
if (term && term.length > 0) {
whereClause.name = {
contains: term,
mode: Prisma.QueryMode.insensitive,
};
}
const [data, count] = await Promise.all([
prisma.teamPending.findMany({
where: whereClause,
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
orderBy: {
[orderByColumn]: orderByDirection,
},
}),
prisma.teamPending.count({
where: whereClause,
}),
]);
return {
data,
count,
currentPage: Math.max(page, 1),
perPage,
totalPages: Math.ceil(count / perPage),
};
};

View File

@ -0,0 +1,77 @@
import { prisma } from '@documenso/prisma';
import type { Team } from '@documenso/prisma/client';
import { Prisma } from '@documenso/prisma/client';
import type { FindResultSet } from '../../types/find-result-set';
export interface FindTeamsOptions {
userId: number;
term?: string;
page?: number;
perPage?: number;
orderBy?: {
column: keyof Team;
direction: 'asc' | 'desc';
};
}
export const findTeams = async ({
userId,
term,
page = 1,
perPage = 10,
orderBy,
}: FindTeamsOptions) => {
const orderByColumn = orderBy?.column ?? 'name';
const orderByDirection = orderBy?.direction ?? 'desc';
const whereClause: Prisma.TeamWhereInput = {
members: {
some: {
userId,
},
},
};
if (term && term.length > 0) {
whereClause.name = {
contains: term,
mode: Prisma.QueryMode.insensitive,
};
}
const [data, count] = await Promise.all([
prisma.team.findMany({
where: whereClause,
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
orderBy: {
[orderByColumn]: orderByDirection,
},
include: {
members: {
where: {
userId,
},
},
},
}),
prisma.team.count({
where: whereClause,
}),
]);
const maskedData = data.map((team) => ({
...team,
currentTeamMember: team.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,22 @@
import { prisma } from '@documenso/prisma';
export type GetTeamEmailByEmailOptions = {
email: string;
};
export const getTeamEmailByEmail = async ({ email }: GetTeamEmailByEmailOptions) => {
return await prisma.teamEmail.findFirst({
where: {
email,
},
include: {
team: {
select: {
id: true,
name: true,
url: true,
},
},
},
});
};

View File

@ -0,0 +1,22 @@
import { prisma } from '@documenso/prisma';
export type GetTeamInvitationsOptions = {
email: string;
};
export const getTeamInvitations = async ({ email }: GetTeamInvitationsOptions) => {
return await prisma.teamMemberInvite.findMany({
where: {
email,
},
include: {
team: {
select: {
id: true,
name: true,
url: true,
},
},
},
});
};

View File

@ -0,0 +1,52 @@
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
export type GetTeamMembersOptions = {
/**
* The optional ID of the user initiating the request.
*
* If provided, the user will be checked to ensure they are a member of the team.
*/
userId?: number;
/**
* The ID of the team to retrieve members from.
*/
teamId: number;
};
/**
* Get all team members for a given teamId.
*
* Provide an optional userId to check that the user is a member of the team.
*/
export const getTeamMembers = async ({ userId, teamId }: GetTeamMembersOptions) => {
const teamMembers = await prisma.teamMember.findMany({
where: {
teamId,
},
include: {
user: {
select: {
id: true,
email: true,
name: true,
},
},
},
});
if (userId !== undefined) {
const teamMember = teamMembers.find((teamMember) => teamMember.userId === userId);
if (!teamMember) {
throw new AppError(
AppErrorCode.UNAUTHORIZED,
`User ${userId} is not a member of team ${teamId}`,
);
}
}
return teamMembers;
};

View File

@ -0,0 +1,113 @@
import { prisma } from '@documenso/prisma';
import type { Prisma } from '@documenso/prisma/client';
export type GetTeamsOptions = {
userId: number;
};
export type GetTeamsResponse = Awaited<ReturnType<typeof getTeams>>;
export const getTeams = async ({ userId }: GetTeamsOptions) => {
const teams = await prisma.team.findMany({
where: {
members: {
some: {
userId,
},
},
},
include: {
members: {
where: {
userId,
},
select: {
role: true,
},
},
},
});
return teams.map(({ members, ...team }) => ({
...team,
currentTeamMember: members[0],
}));
};
export type GetTeamByIdOptions = {
userId?: number;
teamId: number;
};
/**
* Get a team given a teamId.
*
* Provide an optional userId to check that the user is a member of the team.
*/
export const getTeamById = async ({ userId, teamId }: GetTeamByIdOptions) => {
const whereFilter: Prisma.TeamWhereUniqueInput = {
id: teamId,
};
if (userId !== undefined) {
whereFilter['members'] = {
some: {
userId,
},
};
}
return await prisma.team.findUniqueOrThrow({
where: whereFilter,
include: {
teamEmail: true,
},
});
};
export type GetTeamByUrlOptions = {
userId?: number;
teamUrl: string;
};
/**
* Get a team given a teamId.
*
* Provide an optional userId to check that the user is a member of the team.
*/
export const getTeamByUrl = async ({ userId, teamUrl }: GetTeamByUrlOptions) => {
const whereFilter: Prisma.TeamWhereUniqueInput = {
url: teamUrl,
};
if (userId !== undefined) {
whereFilter['members'] = {
some: {
userId,
},
};
}
const result = await prisma.team.findUniqueOrThrow({
where: whereFilter,
include: {
teamEmail: true,
emailVerification: true,
transferVerification: true,
members: {
where: {
userId,
},
select: {
role: true,
},
},
},
});
const { members, ...team } = result;
return {
...team,
currentTeamMember: members[0],
};
};

View File

@ -0,0 +1,27 @@
import { prisma } from '@documenso/prisma';
export type LeaveTeamOptions = {
/**
* The ID of the user who is leaving the team.
*/
userId: number;
/**
* The ID of the team the user is leaving.
*/
teamId: number;
};
export const leaveTeam = async ({ userId, teamId }: LeaveTeamOptions) => {
await prisma.teamMember.deleteMany({
where: {
teamId,
userId,
team: {
ownerUserId: {
not: userId,
},
},
},
});
};

View File

@ -0,0 +1,97 @@
import { createElement } from 'react';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { TeamTransferRequestTemplate } from '@documenso/email/templates/team-transfer-request';
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import { createTokenVerification } from '@documenso/lib/utils/token-verification';
import { prisma } from '@documenso/prisma';
export type RequestTeamOwnershipTransferOptions = {
/**
* The ID of the user initiating the transfer.
*/
userId: number;
/**
* The name of the user initiating the transfer.
*/
userName: string;
/**
* The ID of the team whose ownership is being transferred.
*/
teamId: number;
/**
* The user ID of the new owner.
*/
newOwnerUserId: number;
};
export const requestTeamOwnershipTransfer = async ({
userId,
userName,
teamId,
newOwnerUserId,
}: RequestTeamOwnershipTransferOptions) => {
await prisma.$transaction(async (tx) => {
const team = await tx.team.findFirstOrThrow({
where: {
id: teamId,
ownerUserId: userId,
members: {
some: {
userId: newOwnerUserId,
},
},
},
});
const newOwnerUser = await tx.user.findFirstOrThrow({
where: {
id: newOwnerUserId,
},
});
const { token, expiresAt } = createTokenVerification({ minute: 10 });
const teamVerificationPayload = {
teamId,
token,
expiresAt,
userId: newOwnerUserId,
name: newOwnerUser.name ?? '',
email: newOwnerUser.email,
};
await tx.teamTransferVerification.upsert({
where: {
teamId,
},
create: teamVerificationPayload,
update: teamVerificationPayload,
});
const template = createElement(TeamTransferRequestTemplate, {
assetBaseUrl: WEBAPP_BASE_URL,
baseUrl: WEBAPP_BASE_URL,
senderName: userName,
teamName: team.name,
teamUrl: team.url,
token,
});
await mailer.sendMail({
to: newOwnerUser.email,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: `You have been requested to take ownership of team ${team.name} on Documenso`,
html: render(template),
text: render(template, { plainText: true }),
});
});
};

View File

@ -0,0 +1,65 @@
import { prisma } from '@documenso/prisma';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams';
import { AppError } from '../../errors/app-error';
import { createTokenVerification } from '../../utils/token-verification';
import { sendTeamEmailVerificationEmail } from './add-team-email-verification';
export type ResendTeamMemberInvitationOptions = {
userId: number;
teamId: number;
};
/**
* Resend a team email verification with a new token.
*/
export const resendTeamEmailVerification = async ({
userId,
teamId,
}: ResendTeamMemberInvitationOptions) => {
await prisma.$transaction(async (tx) => {
const team = await tx.team.findUniqueOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
role: {
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
},
},
},
},
include: {
emailVerification: true,
},
});
if (!team) {
throw new AppError('TeamNotFound', 'User is not a member of the team.');
}
const { emailVerification } = team;
if (!emailVerification) {
throw new AppError(
'VerificationNotFound',
'No team email verification exists for this team.',
);
}
const { token, expiresAt } = createTokenVerification({ hours: 1 });
await tx.teamEmailVerification.update({
where: {
teamId,
},
data: {
token,
expiresAt,
},
});
await sendTeamEmailVerificationEmail(emailVerification.email, token, team.name, team.url);
});
};

View File

@ -0,0 +1,76 @@
import { prisma } from '@documenso/prisma';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams';
import { AppError } from '../../errors/app-error';
import { sendTeamMemberInviteEmail } from './create-team-member-invites';
export type ResendTeamMemberInvitationOptions = {
/**
* The ID of the user who is initiating this action.
*/
userId: number;
/**
* The name of hte user who is initiating this action.
*/
userName: string;
/**
* The ID of the team.
*/
teamId: number;
/**
* The IDs of the invitations to resend.
*/
invitationId: number;
};
/**
* Resend an email for a given team member invite.
*/
export const resendTeamMemberInvitation = async ({
userId,
userName,
teamId,
invitationId,
}: ResendTeamMemberInvitationOptions) => {
await prisma.$transaction(async (tx) => {
const team = await tx.team.findUniqueOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
role: {
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
},
},
},
},
});
if (!team) {
throw new AppError('TeamNotFound', 'User is not a member of the team.');
}
const teamMemberInvite = await tx.teamMemberInvite.findUniqueOrThrow({
where: {
id: invitationId,
teamId,
},
});
if (!teamMemberInvite) {
throw new AppError('InviteNotFound', 'No invite exists for this user.');
}
await sendTeamMemberInviteEmail({
email: teamMemberInvite.email,
token: teamMemberInvite.token,
teamName: team.name,
teamUrl: team.url,
senderName: userName,
});
});
};

View File

@ -0,0 +1,86 @@
import type Stripe from 'stripe';
import { transferTeamSubscription } from '@documenso/ee/server-only/stripe/transfer-team-subscription';
import { mapStripeSubscriptionToPrismaUpsertAction } from '@documenso/ee/server-only/stripe/webhook/on-subscription-updated';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { prisma } from '@documenso/prisma';
import { TeamMemberRole } from '@documenso/prisma/client';
export type TransferTeamOwnershipOptions = {
token: string;
};
export const transferTeamOwnership = async ({ token }: TransferTeamOwnershipOptions) => {
await prisma.$transaction(async (tx) => {
const teamTransferVerification = await tx.teamTransferVerification.findFirstOrThrow({
where: {
token,
},
include: {
team: true,
},
});
const { team, userId: newOwnerUserId } = teamTransferVerification;
await tx.teamTransferVerification.deleteMany({
where: {
teamId: team.id,
},
});
const newOwnerUser = await tx.user.findFirstOrThrow({
where: {
id: newOwnerUserId,
},
include: {
Subscription: true,
},
});
let newTeamSubscription: Stripe.Subscription | null = null;
if (IS_BILLING_ENABLED) {
newTeamSubscription = await transferTeamSubscription({
user: newOwnerUser,
team,
});
}
if (newTeamSubscription) {
await tx.subscription.upsert(
mapStripeSubscriptionToPrismaUpsertAction(newOwnerUser.id, newTeamSubscription),
);
}
// Todo: Teams - Add billing message in email indicating that billing will be passed on when transferring a team.
await tx.team.update({
where: {
id: team.id,
members: {
some: {
userId: newOwnerUserId,
},
},
},
data: {
ownerUserId: newOwnerUserId,
subscriptionId: newTeamSubscription?.id ?? null,
members: {
update: {
where: {
userId_teamId: {
teamId: team.id,
userId: newOwnerUserId,
},
},
data: {
role: TeamMemberRole.ADMIN,
},
},
},
},
});
});
};

View File

@ -0,0 +1,41 @@
import { prisma } from '@documenso/prisma';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams';
export type UpdateTeamEmailOptions = {
userId: number;
teamId: number;
data: {
name?: string;
};
};
export const updateTeamEmail = async ({ userId, teamId, data }: UpdateTeamEmailOptions) => {
await prisma.$transaction(async (tx) => {
await tx.team.findFirstOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
role: {
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
},
},
},
teamEmail: {
isNot: null,
},
},
});
await tx.teamEmail.update({
where: {
teamId,
},
data: {
...data,
},
});
});
};

View File

@ -0,0 +1,50 @@
import { prisma } from '@documenso/prisma';
import { TeamMemberRole } from '@documenso/prisma/client';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams';
export type UpdateTeamMemberOptions = {
userId: number;
teamId: number;
teamMemberId: number;
data: {
role: TeamMemberRole;
};
};
export const updateTeamMember = async ({
userId,
teamId,
teamMemberId,
data,
}: UpdateTeamMemberOptions) => {
await prisma.$transaction(async (tx) => {
// Find the team and validate that the user is allowed to update members.
const team = await tx.team.findFirstOrThrow({
where: {
id: teamId,
members: {
some: {
userId,
role: {
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['UPDATE_TEAM_MEMBERS'],
},
},
},
},
});
return await tx.teamMember.update({
where: {
id: teamMemberId,
teamId,
userId: {
not: team.ownerUserId,
},
},
data: {
role: data.role,
},
});
});
};

View File

@ -0,0 +1,51 @@
import { z } from 'zod';
import { prisma } from '@documenso/prisma';
import { Prisma } from '@documenso/prisma/client';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams';
import { AppError, AppErrorCode } from '../../errors/app-error';
export type UpdateTeamOptions = {
userId: number;
teamId: number;
data: {
name?: string;
url?: string;
};
};
export const updateTeam = async ({ userId, teamId, data }: UpdateTeamOptions) => {
try {
return await prisma.team.update({
where: {
id: teamId,
members: {
some: {
userId,
role: {
in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
},
},
},
},
data: {
...data,
},
});
} 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, 'Team URL already exists.');
}
throw err;
}
};

View File

@ -1,11 +1,13 @@
import { hash } from '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 } from '@documenso/prisma/client';
import { IdentityProvider, Prisma, TeamMemberInviteStatus } from '@documenso/prisma/client';
import { IS_BILLING_ENABLED } from '../../constants/app';
import { SALT_ROUNDS } from '../../constants/auth';
import { getFlag } from '../../universal/get-feature-flag';
import { getTeamSeatPriceId } from '../../utils/billing';
export interface CreateUserOptions {
name: string;
@ -15,8 +17,6 @@ export interface CreateUserOptions {
}
export const createUser = async ({ name, email, password, signature }: CreateUserOptions) => {
const isBillingEnabled = await getFlag('app_billing');
const hashedPassword = await hash(password, SALT_ROUNDS);
const userExists = await prisma.user.findFirst({
@ -29,24 +29,77 @@ export const createUser = async ({ name, email, password, signature }: CreateUse
throw new Error('User already exists');
}
let user = await prisma.user.create({
data: {
name,
email: email.toLowerCase(),
password: hashedPassword,
signature,
identityProvider: IdentityProvider.DOCUMENSO,
},
});
return prisma.$transaction(async (tx) => {
const user = await tx.user.create({
data: {
name,
email: email.toLowerCase(),
password: hashedPassword,
signature,
identityProvider: IdentityProvider.DOCUMENSO,
},
});
if (isBillingEnabled) {
try {
const stripeSession = await getStripeCustomerByUser(user);
user = stripeSession.user;
} catch (e) {
console.error(e);
const acceptedTeamInvites = await tx.teamMemberInvite.findMany({
where: {
email: {
equals: email,
mode: Prisma.QueryMode.insensitive,
},
status: TeamMemberInviteStatus.ACCEPTED,
},
});
// For each team invite, add the user to the team and delete the team invite.
await Promise.all(
acceptedTeamInvites.map(async (invite) => {
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) {
const team = await tx.team.findFirstOrThrow({
where: {
id: invite.teamId,
},
include: {
members: {
select: {
id: true,
},
},
},
});
if (team.subscriptionId) {
await updateSubscriptionItemQuantity({
priceId: getTeamSeatPriceId(),
subscriptionId: team.subscriptionId,
quantity: team.members.length,
});
}
}
}),
);
if (IS_BILLING_ENABLED) {
try {
return await getStripeCustomerByUser(user).then((session) => session.user);
} catch (err) {
console.error(err);
}
}
}
return user;
return user;
});
};

View File

@ -0,0 +1,20 @@
import { z } from 'zod';
export const ZBaseTableSearchParamsSchema = z.object({
query: z
.string()
.optional()
.catch(() => undefined),
page: z.coerce
.number()
.min(1)
.optional()
.catch(() => undefined),
perPage: z.coerce
.number()
.min(1)
.optional()
.catch(() => undefined),
});
export type TBaseTableSearchParamsSchema = z.infer<typeof ZBaseTableSearchParamsSchema>;

View File

@ -0,0 +1,26 @@
import { AppError } from '../errors/app-error';
import type { Subscription } from '.prisma/client';
import { SubscriptionStatus } from '.prisma/client';
export const isPriceIdCommunityPlan = (priceId: string) =>
priceId === process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID ||
priceId === process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID;
/**
* Returns true if there is a subscription that is active and is a community plan.
*/
export const isSomeSubscriptionsActiveAndCommunityPlan = (subscriptions: Subscription[]) => {
return subscriptions.some(
(subscription) =>
subscription.status === SubscriptionStatus.ACTIVE &&
isPriceIdCommunityPlan(subscription.planId),
);
};
export const getTeamSeatPriceId = () => {
if (!process.env.NEXT_PUBLIC_STRIPE_TEAM_SEAT_PRICE_ID) {
throw new AppError('MISSING_STRIPE_TEAM_SEAT_PRICE_ID');
}
return process.env.NEXT_PUBLIC_STRIPE_TEAM_SEAT_PRICE_ID;
};

View File

@ -0,0 +1,17 @@
// Common util functions for parsing params.
/**
* From an unknown string, parse it into a number array.
*
* Filter out unknown values.
*/
export const parseToNumberArray = (value: unknown): number[] => {
if (typeof value !== 'string') {
return [];
}
return value
.split(',')
.map((value) => parseInt(value, 10))
.filter((value) => !isNaN(value));
};

View File

@ -0,0 +1,7 @@
import { WEBAPP_BASE_URL } from '../constants/app';
export const formatTeamUrl = (teamUrl: string, baseUrl?: string) => {
const formattedBaseUrl = (baseUrl ?? WEBAPP_BASE_URL).replace(/https?:\/\//, '');
return `${formattedBaseUrl}/t/${teamUrl}`;
};

View File

@ -0,0 +1,21 @@
import type { DurationLike } from 'luxon';
import { DateTime } from 'luxon';
import { nanoid } from 'nanoid';
/**
* Create a token verification object.
*
* @param expiry The date the token expires, or the duration until the token expires.
*/
export const createTokenVerification = (expiry: Date | DurationLike) => {
const expiresAt = expiry instanceof Date ? expiry : DateTime.now().plus(expiry).toJSDate();
return {
expiresAt,
token: nanoid(32),
};
};
export const isTokenExpired = (expiresAt: Date) => {
return expiresAt < new Date();
};

View File

@ -0,0 +1,156 @@
-- CreateEnum
CREATE TYPE "TeamMemberRole" AS ENUM ('ADMIN', 'MANAGER', 'MEMBER');
-- CreateEnum
CREATE TYPE "TeamMemberInviteStatus" AS ENUM ('ACCEPTED', 'PENDING');
-- AlterTable
ALTER TABLE "Document" ADD COLUMN "teamId" INTEGER;
-- CreateTable
CREATE TABLE "Team" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"url" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"subscriptionId" TEXT,
"ownerUserId" INTEGER NOT NULL,
CONSTRAINT "Team_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TeamPending" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"url" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"ownerUserId" INTEGER NOT NULL,
CONSTRAINT "TeamPending_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TeamMember" (
"id" SERIAL NOT NULL,
"teamId" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"role" "TeamMemberRole" NOT NULL,
"userId" INTEGER NOT NULL,
CONSTRAINT "TeamMember_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TeamEmail" (
"teamId" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL,
CONSTRAINT "TeamEmail_pkey" PRIMARY KEY ("teamId")
);
-- CreateTable
CREATE TABLE "TeamEmailVerification" (
"teamId" INTEGER NOT NULL,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TeamEmailVerification_pkey" PRIMARY KEY ("teamId")
);
-- CreateTable
CREATE TABLE "TeamTransferVerification" (
"teamId" INTEGER NOT NULL,
"userId" INTEGER NOT NULL,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TeamTransferVerification_pkey" PRIMARY KEY ("teamId")
);
-- CreateTable
CREATE TABLE "TeamMemberInvite" (
"id" SERIAL NOT NULL,
"teamId" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"email" TEXT NOT NULL,
"status" "TeamMemberInviteStatus" NOT NULL DEFAULT 'PENDING',
"role" "TeamMemberRole" NOT NULL,
"token" TEXT NOT NULL,
CONSTRAINT "TeamMemberInvite_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Team_url_key" ON "Team"("url");
-- CreateIndex
CREATE UNIQUE INDEX "Team_subscriptionId_key" ON "Team"("subscriptionId");
-- CreateIndex
CREATE UNIQUE INDEX "TeamPending_url_key" ON "TeamPending"("url");
-- CreateIndex
CREATE UNIQUE INDEX "TeamMember_userId_teamId_key" ON "TeamMember"("userId", "teamId");
-- CreateIndex
CREATE UNIQUE INDEX "TeamEmail_teamId_key" ON "TeamEmail"("teamId");
-- CreateIndex
CREATE UNIQUE INDEX "TeamEmail_email_key" ON "TeamEmail"("email");
-- CreateIndex
CREATE UNIQUE INDEX "TeamEmailVerification_teamId_key" ON "TeamEmailVerification"("teamId");
-- CreateIndex
CREATE UNIQUE INDEX "TeamEmailVerification_token_key" ON "TeamEmailVerification"("token");
-- CreateIndex
CREATE UNIQUE INDEX "TeamTransferVerification_teamId_key" ON "TeamTransferVerification"("teamId");
-- CreateIndex
CREATE UNIQUE INDEX "TeamTransferVerification_token_key" ON "TeamTransferVerification"("token");
-- CreateIndex
CREATE UNIQUE INDEX "TeamMemberInvite_token_key" ON "TeamMemberInvite"("token");
-- CreateIndex
CREATE UNIQUE INDEX "TeamMemberInvite_teamId_email_key" ON "TeamMemberInvite"("teamId", "email");
-- AddForeignKey
ALTER TABLE "Document" ADD CONSTRAINT "Document_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Team" ADD CONSTRAINT "Team_ownerUserId_fkey" FOREIGN KEY ("ownerUserId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Team" ADD CONSTRAINT "Team_subscriptionId_fkey" FOREIGN KEY ("subscriptionId") REFERENCES "Subscription"("planId") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TeamPending" ADD CONSTRAINT "TeamPending_ownerUserId_fkey" FOREIGN KEY ("ownerUserId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TeamMember" ADD CONSTRAINT "TeamMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TeamMember" ADD CONSTRAINT "TeamMember_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TeamEmail" ADD CONSTRAINT "TeamEmail_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TeamEmailVerification" ADD CONSTRAINT "TeamEmailVerification_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TeamTransferVerification" ADD CONSTRAINT "TeamTransferVerification_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TeamMemberInvite" ADD CONSTRAINT "TeamMemberInvite_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -37,11 +37,14 @@ model User {
Document Document[]
Subscription Subscription[]
PasswordResetToken PasswordResetToken[]
teamMembers TeamMember[]
teams Team[]
teamsPending TeamPending[]
twoFactorSecret String?
twoFactorEnabled Boolean @default(false)
twoFactorBackupCodes String?
VerificationToken VerificationToken[]
Template Template[]
Template Template[]
@@index([email])
}
@ -82,7 +85,8 @@ model Subscription {
updatedAt DateTime @updatedAt
cancelAtPeriodEnd Boolean @default(false)
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
Team Team?
@@index([userId])
}
@ -136,6 +140,8 @@ model Document {
updatedAt DateTime @default(now()) @updatedAt
completedAt DateTime?
deletedAt DateTime?
teamId Int?
team Team? @relation(fields: [teamId], references: [id])
@@unique([documentDataId])
@@index([userId])
@ -181,19 +187,19 @@ enum SigningStatus {
}
model Recipient {
id Int @id @default(autoincrement())
id Int @id @default(autoincrement())
documentId Int?
templateId Int?
email String @db.VarChar(255)
name String @default("") @db.VarChar(255)
email String @db.VarChar(255)
name String @default("") @db.VarChar(255)
token String
expired DateTime?
signedAt DateTime?
readStatus ReadStatus @default(NOT_OPENED)
signingStatus SigningStatus @default(NOT_SIGNED)
sendStatus SendStatus @default(NOT_SENT)
Document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade)
Template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade)
Document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade)
Template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade)
Field Field[]
Signature Signature[]
@ -263,6 +269,101 @@ model DocumentShareLink {
@@unique([documentId, email])
}
enum TeamMemberRole {
ADMIN
MANAGER
MEMBER
}
enum TeamMemberInviteStatus {
ACCEPTED
PENDING
}
model Team {
id Int @id @default(autoincrement())
name String
url String @unique
createdAt DateTime @default(now())
subscriptionId String? @unique
ownerUserId Int
members TeamMember[]
invites TeamMemberInvite[]
teamEmail TeamEmail?
emailVerification TeamEmailVerification?
transferVerification TeamTransferVerification?
owner User @relation(fields: [ownerUserId], references: [id])
subscription Subscription? @relation(fields: [subscriptionId], references: [planId])
document Document[]
}
model TeamPending {
id Int @id @default(autoincrement())
name String
url String @unique
createdAt DateTime @default(now())
ownerUserId Int
owner User @relation(fields: [ownerUserId], references: [id])
}
model TeamMember {
id Int @id @default(autoincrement())
teamId Int
createdAt DateTime @default(now())
role TeamMemberRole
userId Int
user User @relation(fields: [userId], references: [id])
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
@@unique([userId, teamId])
}
model TeamEmail {
teamId Int @id @unique
createdAt DateTime @default(now())
name String
email String @unique
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
}
model TeamEmailVerification {
teamId Int @id @unique
name String
email String
token String @unique
expiresAt DateTime
createdAt DateTime @default(now())
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
}
model TeamTransferVerification {
teamId Int @id @unique
userId Int
name String
email String
token String @unique
expiresAt DateTime
createdAt DateTime @default(now())
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
}
model TeamMemberInvite {
id Int @id @default(autoincrement())
teamId Int
createdAt DateTime @default(now())
email String
status TeamMemberInviteStatus @default(PENDING)
role TeamMemberRole
token String @unique
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
@@unique([teamId, email])
}
enum TemplateType {
PUBLIC
PRIVATE
@ -277,10 +378,10 @@ model Template {
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
templateDocumentData DocumentData @relation(fields: [templateDocumentDataId], references: [id], onDelete: Cascade)
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
Recipient Recipient[]
Field Field[]
templateDocumentData DocumentData @relation(fields: [templateDocumentDataId], references: [id], onDelete: Cascade)
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
Recipient Recipient[]
Field Field[]
@@unique([templateDocumentDataId])
}

View File

@ -70,20 +70,24 @@ export const documentRouter = router({
.input(ZCreateDocumentMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
const { title, documentDataId } = input;
const { title, documentDataId, teamId } = input;
const { remaining } = await getServerLimits({ email: ctx.user.email });
// Teams bypass document limits.
if (teamId !== undefined) {
const { remaining } = await getServerLimits({ email: ctx.user.email });
if (remaining.documents <= 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message:
'You have reached your document limit for this month. Please upgrade your plan.',
});
if (remaining.documents <= 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message:
'You have reached your document limit for this month. Please upgrade your plan.',
});
}
}
return await createDocument({
userId: ctx.user.id,
teamId,
title,
documentDataId,
});

View File

@ -17,6 +17,7 @@ export type TGetDocumentByTokenQuerySchema = z.infer<typeof ZGetDocumentByTokenQ
export const ZCreateDocumentMutationSchema = z.object({
title: z.string().min(1),
documentDataId: z.string().min(1),
teamId: z.number().optional(),
});
export type TCreateDocumentMutationSchema = z.infer<typeof ZCreateDocumentMutationSchema>;

View File

@ -6,6 +6,7 @@ import { profileRouter } from './profile-router/router';
import { recipientRouter } from './recipient-router/router';
import { shareLinkRouter } from './share-link-router/router';
import { singleplayerRouter } from './singleplayer-router/router';
import { teamRouter } from './team-router/router';
import { templateRouter } from './template-router/router';
import { router } from './trpc';
import { twoFactorAuthenticationRouter } from './two-factor-authentication-router/router';
@ -19,8 +20,9 @@ export const appRouter = router({
admin: adminRouter,
shareLink: shareLinkRouter,
singleplayer: singleplayerRouter,
twoFactorAuthentication: twoFactorAuthenticationRouter,
team: teamRouter,
template: templateRouter,
twoFactorAuthentication: twoFactorAuthenticationRouter,
});
export type AppRouter = typeof appRouter;

View File

@ -0,0 +1,479 @@
import { AppError } from '@documenso/lib/errors/app-error';
import { acceptTeamInvitation } from '@documenso/lib/server-only/team/accept-team-invitation';
import { addTeamEmailVerification } from '@documenso/lib/server-only/team/add-team-email-verification';
import { createTeam } from '@documenso/lib/server-only/team/create-team';
import { createTeamPendingCheckoutSession } from '@documenso/lib/server-only/team/create-team-checkout-session';
import { createTeamMemberInvites } from '@documenso/lib/server-only/team/create-team-member-invites';
import { deleteTeam } from '@documenso/lib/server-only/team/delete-team';
import { deleteTeamEmail } from '@documenso/lib/server-only/team/delete-team-email';
import { deleteTeamEmailVerification } from '@documenso/lib/server-only/team/delete-team-email-verification';
import { deleteTeamMemberInvitations } from '@documenso/lib/server-only/team/delete-team-invitations';
import { deleteTeamMembers } from '@documenso/lib/server-only/team/delete-team-members';
import { deleteTeamPending } from '@documenso/lib/server-only/team/delete-team-pending';
import { deleteTeamTransferRequest } from '@documenso/lib/server-only/team/delete-team-transfer-request';
import { findTeamInvoices } from '@documenso/lib/server-only/team/find-team-invoices';
import { findTeamMemberInvites } from '@documenso/lib/server-only/team/find-team-member-invites';
import { findTeamMembers } from '@documenso/lib/server-only/team/find-team-members';
import { findTeams } from '@documenso/lib/server-only/team/find-teams';
import { findTeamsPending } from '@documenso/lib/server-only/team/find-teams-pending';
import { getTeamEmailByEmail } from '@documenso/lib/server-only/team/get-team-email-by-email';
import { getTeamInvitations } from '@documenso/lib/server-only/team/get-team-invitations';
import { getTeamMembers } from '@documenso/lib/server-only/team/get-team-members';
import { getTeamById, getTeams } from '@documenso/lib/server-only/team/get-teams';
import { leaveTeam } from '@documenso/lib/server-only/team/leave-team';
import { requestTeamOwnershipTransfer } from '@documenso/lib/server-only/team/request-team-ownership-transfer';
import { resendTeamEmailVerification } from '@documenso/lib/server-only/team/resend-team-email-verification';
import { resendTeamMemberInvitation } from '@documenso/lib/server-only/team/resend-team-member-invitation';
import { updateTeam } from '@documenso/lib/server-only/team/update-team';
import { updateTeamEmail } from '@documenso/lib/server-only/team/update-team-email';
import { updateTeamMember } from '@documenso/lib/server-only/team/update-team-member';
import { authenticatedProcedure, router } from '../trpc';
import {
ZAcceptTeamInvitationMutationSchema,
ZAddTeamEmailVerificationMutationSchema,
ZCreateTeamMemberInvitesMutationSchema,
ZCreateTeamMutationSchema,
ZCreateTeamPendingCheckoutMutationSchema,
ZDeleteTeamEmailMutationSchema,
ZDeleteTeamEmailVerificationMutationSchema,
ZDeleteTeamMemberInvitationsMutationSchema,
ZDeleteTeamMembersMutationSchema,
ZDeleteTeamMutationSchema,
ZDeleteTeamPendingMutationSchema,
ZDeleteTeamTransferRequestMutationSchema,
ZFindTeamInvoicesQuerySchema,
ZFindTeamMemberInvitesQuerySchema,
ZFindTeamMembersQuerySchema,
ZFindTeamsPendingQuerySchema,
ZFindTeamsQuerySchema,
ZGetTeamMembersQuerySchema,
ZGetTeamQuerySchema,
ZLeaveTeamMutationSchema,
ZRequestTeamOwnerhsipTransferMutationSchema,
ZResendTeamEmailVerificationMutationSchema,
ZResendTeamMemberInvitationMutationSchema,
ZUpdateTeamEmailMutationSchema,
ZUpdateTeamMemberMutationSchema,
ZUpdateTeamMutationSchema,
} from './schema';
export const teamRouter = 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);
}
}),
addTeamEmailVerification: authenticatedProcedure
.input(ZAddTeamEmailVerificationMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
return await addTeamEmailVerification({
teamId: input.teamId,
userId: ctx.user.id,
data: {
email: input.email,
name: input.name,
},
});
} catch (err) {
console.error(err);
throw AppError.parseErrorToTRPCError(err);
}
}),
createTeam: authenticatedProcedure
.input(ZCreateTeamMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
const { name, url } = input;
return await createTeam({
userId: ctx.user.id,
name,
teamUrl: url,
});
} catch (err) {
console.error(err);
throw AppError.parseErrorToTRPCError(err);
}
}),
createTeamMemberInvites: authenticatedProcedure
.input(ZCreateTeamMemberInvitesMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
return await createTeamMemberInvites({
userId: ctx.user.id,
userName: ctx.user.name ?? '',
...input,
});
} catch (err) {
console.error(err);
throw AppError.parseErrorToTRPCError(err);
}
}),
createTeamPendingCheckout: authenticatedProcedure
.input(ZCreateTeamPendingCheckoutMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
return await createTeamPendingCheckoutSession({
userId: ctx.user.id,
...input,
});
} catch (err) {
console.error(err);
throw AppError.parseErrorToTRPCError(err);
}
}),
deleteTeam: authenticatedProcedure
.input(ZDeleteTeamMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
return await deleteTeam({
userId: ctx.user.id,
...input,
});
} catch (err) {
console.error(err);
throw AppError.parseErrorToTRPCError(err);
}
}),
deleteTeamEmail: authenticatedProcedure
.input(ZDeleteTeamEmailMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
return await deleteTeamEmail({
teamId: input.teamId,
userId: ctx.user.id,
userEmail: ctx.user.email,
});
} catch (err) {
console.error(err);
throw AppError.parseErrorToTRPCError(err);
}
}),
deleteTeamEmailVerification: authenticatedProcedure
.input(ZDeleteTeamEmailVerificationMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
return await deleteTeamEmailVerification({ teamId: input.teamId, userId: ctx.user.id });
} catch (err) {
console.error(err);
throw AppError.parseErrorToTRPCError(err);
}
}),
deleteTeamMemberInvitations: authenticatedProcedure
.input(ZDeleteTeamMemberInvitationsMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
return await deleteTeamMemberInvitations({
userId: ctx.user.id,
...input,
});
} catch (err) {
console.error(err);
throw AppError.parseErrorToTRPCError(err);
}
}),
deleteTeamMembers: authenticatedProcedure
.input(ZDeleteTeamMembersMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
return await deleteTeamMembers({
userId: ctx.user.id,
...input,
});
} catch (err) {
console.error(err);
throw AppError.parseErrorToTRPCError(err);
}
}),
deleteTeamPending: authenticatedProcedure
.input(ZDeleteTeamPendingMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
return await deleteTeamPending({
userId: ctx.user.id,
...input,
});
} catch (err) {
console.error(err);
throw AppError.parseErrorToTRPCError(err);
}
}),
deleteTeamTransferRequest: authenticatedProcedure
.input(ZDeleteTeamTransferRequestMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
return await deleteTeamTransferRequest({
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);
}
}),
findTeamMemberInvites: authenticatedProcedure
.input(ZFindTeamMemberInvitesQuerySchema)
.query(async ({ input, ctx }) => {
try {
return await findTeamMemberInvites({
userId: ctx.user.id,
...input,
});
} catch (err) {
console.error(err);
throw AppError.parseErrorToTRPCError(err);
}
}),
findTeamMembers: authenticatedProcedure
.input(ZFindTeamMembersQuerySchema)
.query(async ({ input, ctx }) => {
try {
return await findTeamMembers({
userId: ctx.user.id,
...input,
});
} catch (err) {
console.error(err);
throw AppError.parseErrorToTRPCError(err);
}
}),
findTeams: authenticatedProcedure.input(ZFindTeamsQuerySchema).query(async ({ input, ctx }) => {
try {
return await findTeams({
userId: ctx.user.id,
...input,
});
} catch (err) {
console.error(err);
throw AppError.parseErrorToTRPCError(err);
}
}),
findTeamsPending: authenticatedProcedure
.input(ZFindTeamsPendingQuerySchema)
.query(async ({ input, ctx }) => {
try {
return await findTeamsPending({
userId: ctx.user.id,
...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);
}
}),
getTeams: authenticatedProcedure.query(async ({ ctx }) => {
try {
return await getTeams({ userId: ctx.user.id });
} catch (err) {
console.error(err);
throw AppError.parseErrorToTRPCError(err);
}
}),
leaveTeam: authenticatedProcedure
.input(ZLeaveTeamMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
return await leaveTeam({
userId: ctx.user.id,
...input,
});
} catch (err) {
console.error(err);
throw AppError.parseErrorToTRPCError(err);
}
}),
updateTeam: authenticatedProcedure
.input(ZUpdateTeamMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
return await updateTeam({
userId: ctx.user.id,
...input,
});
} catch (err) {
console.error(err);
throw AppError.parseErrorToTRPCError(err);
}
}),
updateTeamEmail: authenticatedProcedure
.input(ZUpdateTeamEmailMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
return await updateTeamEmail({
userId: ctx.user.id,
...input,
});
} catch (err) {
console.error(err);
throw AppError.parseErrorToTRPCError(err);
}
}),
updateTeamMember: authenticatedProcedure
.input(ZUpdateTeamMemberMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
return await updateTeamMember({
userId: ctx.user.id,
...input,
});
} catch (err) {
console.error(err);
throw AppError.parseErrorToTRPCError(err);
}
}),
requestTeamOwnershipTransfer: authenticatedProcedure
.input(ZRequestTeamOwnerhsipTransferMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
return await requestTeamOwnershipTransfer({
userId: ctx.user.id,
userName: ctx.user.name ?? '',
...input,
});
} catch (err) {
console.error(err);
throw AppError.parseErrorToTRPCError(err);
}
}),
resendTeamEmailVerification: authenticatedProcedure
.input(ZResendTeamEmailVerificationMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
await resendTeamEmailVerification({
userId: ctx.user.id,
...input,
});
} catch (err) {
console.error(err);
throw AppError.parseErrorToTRPCError(err);
}
}),
resendTeamMemberInvitation: authenticatedProcedure
.input(ZResendTeamMemberInvitationMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
await resendTeamMemberInvitation({
userId: ctx.user.id,
userName: ctx.user.name ?? '',
...input,
});
} catch (err) {
console.error(err);
throw AppError.parseErrorToTRPCError(err);
}
}),
});

View File

@ -0,0 +1,170 @@
import { z } from 'zod';
import { TeamMemberRole } from '@documenso/prisma/client';
const GenericFindQuerySchema = z.object({
term: z.string().optional(),
page: z.number().optional(),
perPage: z.number().optional(),
});
export const ZAcceptTeamInvitationMutationSchema = z.object({
teamId: z.number(),
});
export const ZAddTeamEmailVerificationMutationSchema = z.object({
teamId: z.number(),
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
email: z.string().trim().email().min(1, 'Please enter a valid email.'),
});
export const ZCreateTeamMutationSchema = z.object({
name: z.string().min(1),
url: z.string().min(1), // Todo: Teams - Apply lowercase, disallow certain symbols, disallow profanity.
});
export const ZCreateTeamMemberInvitesMutationSchema = z.object({
teamId: z.number(),
invitations: z.array(
z.object({
email: z.string().email(),
role: z.nativeEnum(TeamMemberRole),
}),
),
});
export const ZCreateTeamPendingCheckoutMutationSchema = z.object({
pendingTeamId: z.number(),
});
export const ZDeleteTeamEmailMutationSchema = z.object({
teamId: z.number(),
});
export const ZDeleteTeamEmailVerificationMutationSchema = z.object({
teamId: z.number(),
});
export const ZDeleteTeamMembersMutationSchema = z.object({
teamId: z.number(),
teamMemberIds: z.array(z.number()),
});
export const ZDeleteTeamMemberInvitationsMutationSchema = z.object({
teamId: z.number(),
invitationIds: z.array(z.number()),
});
export const ZDeleteTeamMutationSchema = z.object({
teamId: z.number(),
});
export const ZDeleteTeamPendingMutationSchema = z.object({
pendingTeamId: z.number(),
});
export const ZDeleteTeamTransferRequestMutationSchema = z.object({
teamId: z.number(),
});
export const ZFindTeamInvoicesQuerySchema = z.object({
teamId: z.number(),
});
export const ZFindTeamMemberInvitesQuerySchema = GenericFindQuerySchema.extend({
teamId: z.number(),
});
export const ZFindTeamMembersQuerySchema = GenericFindQuerySchema.extend({
teamId: z.number(),
});
export const ZFindTeamsQuerySchema = GenericFindQuerySchema;
export const ZFindTeamsPendingQuerySchema = GenericFindQuerySchema;
export const ZGetTeamQuerySchema = z.object({
teamId: z.number(),
});
export const ZGetTeamMembersQuerySchema = z.object({
teamId: z.number(),
});
export const ZLeaveTeamMutationSchema = z.object({
teamId: z.number(),
});
export const ZUpdateTeamMutationSchema = z.object({
teamId: z.number(),
data: z.object({
// Todo: Teams
name: z.string().min(1),
url: z.string().min(1), // Todo: Apply regex. Todo: lowercase, etc
}),
});
export const ZUpdateTeamEmailMutationSchema = z.object({
teamId: z.number(),
data: z.object({
name: z.string().min(1),
}),
});
export const ZUpdateTeamMemberMutationSchema = z.object({
teamId: z.number(),
teamMemberId: z.number(),
data: z.object({
role: z.nativeEnum(TeamMemberRole),
}),
});
export const ZRequestTeamOwnerhsipTransferMutationSchema = z.object({
teamId: z.number(),
newOwnerUserId: z.number(),
});
export const ZResendTeamEmailVerificationMutationSchema = z.object({
teamId: z.number(),
});
export const ZResendTeamMemberInvitationMutationSchema = z.object({
teamId: z.number(),
invitationId: z.number(),
});
export type TAddTeamEmailVerificationMutationSchema = z.infer<
typeof ZAddTeamEmailVerificationMutationSchema
>;
export type TCreateTeamMutationSchema = z.infer<typeof ZCreateTeamMutationSchema>;
export type TCreateTeamMemberInvitesMutationSchema = z.infer<
typeof ZCreateTeamMemberInvitesMutationSchema
>;
export type TCreateTeamPendingCheckoutMutationSchema = z.infer<
typeof ZCreateTeamPendingCheckoutMutationSchema
>;
export type TDeleteTeamEmailMutationSchema = z.infer<typeof ZDeleteTeamEmailMutationSchema>;
export type TDeleteTeamMembersMutationSchema = z.infer<typeof ZDeleteTeamMembersMutationSchema>;
export type TDeleteTeamMutationSchema = z.infer<typeof ZDeleteTeamMutationSchema>;
export type TDeleteTeamPendingMutationSchema = z.infer<typeof ZDeleteTeamPendingMutationSchema>;
export type TDeleteTeamTransferRequestMutationSchema = z.infer<
typeof ZDeleteTeamTransferRequestMutationSchema
>;
export type TFindTeamMemberInvitesQuerySchema = z.infer<typeof ZFindTeamMembersQuerySchema>;
export type TFindTeamMembersQuerySchema = z.infer<typeof ZFindTeamMembersQuerySchema>;
export type TFindTeamsQuerySchema = z.infer<typeof ZFindTeamsQuerySchema>;
export type TFindTeamsPendingQuerySchema = z.infer<typeof ZFindTeamsPendingQuerySchema>;
export type TGetTeamQuerySchema = z.infer<typeof ZGetTeamQuerySchema>;
export type TGetTeamMembersQuerySchema = z.infer<typeof ZGetTeamMembersQuerySchema>;
export type TLeaveTeamMutationSchema = z.infer<typeof ZLeaveTeamMutationSchema>;
export type TUpdateTeamMutationSchema = z.infer<typeof ZUpdateTeamMutationSchema>;
export type TUpdateTeamEmailMutationSchema = z.infer<typeof ZUpdateTeamEmailMutationSchema>;
export type TRequestTeamOwnerhsipTransferMutationSchema = z.infer<
typeof ZRequestTeamOwnerhsipTransferMutationSchema
>;
export type TResendTeamEmailVerificationMutationSchema = z.infer<
typeof ZResendTeamEmailVerificationMutationSchema
>;
export type TResendTeamMemberInvitationMutationSchema = z.infer<
typeof ZResendTeamMemberInvitationMutationSchema
>;

View File

@ -10,6 +10,7 @@ declare namespace NodeJS {
NEXT_PRIVATE_ENCRYPTION_KEY: string;
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string;
NEXT_PUBLIC_STRIPE_TEAM_SEAT_PRICE_ID?: string;
NEXT_PRIVATE_STRIPE_API_KEY: string;
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;

View File

@ -48,4 +48,37 @@ const AvatarFallback = React.forwardRef<
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback };
type AvatarWithTextProps = {
avatarClass?: string;
avatarFallback: string;
className?: string;
primaryText: React.ReactNode;
secondaryText?: React.ReactNode;
rightSideComponent?: React.ReactNode;
};
const AvatarWithText = ({
avatarClass,
avatarFallback,
className,
primaryText,
secondaryText,
rightSideComponent,
}: AvatarWithTextProps) => (
<div className={cn('flex w-full max-w-xs items-center gap-2', className)}>
<Avatar
className={cn('dark:border-border h-10 w-10 border-2 border-solid border-white', avatarClass)}
>
<AvatarFallback className="text-xs text-gray-400">{avatarFallback}</AvatarFallback>
</Avatar>
<div className="flex flex-col text-left text-sm font-normal">
<span className="text-foreground">{primaryText}</span>
<span className="text-muted-foreground text-xs">{secondaryText}</span>
</div>
{rightSideComponent}
</div>
);
export { Avatar, AvatarImage, AvatarFallback, AvatarWithText };

View File

@ -1,6 +1,7 @@
import * as React from 'react';
import { VariantProps, cva } from 'class-variance-authority';
import type { VariantProps } from 'class-variance-authority';
import { cva } from 'class-variance-authority';
import { cn } from '../lib/utils';

View File

@ -1,77 +1,162 @@
'use client';
import * as React from 'react';
import { Check, ChevronsUpDown } from 'lucide-react';
import { Role } from '@documenso/prisma/client';
import { AnimatePresence, motion } from 'framer-motion';
import { Check, ChevronsUpDown, Loader, XIcon } from 'lucide-react';
import { cn } from '../lib/utils';
import { Button } from './button';
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from './command';
import { Popover, PopoverContent, PopoverTrigger } from './popover';
type ComboboxProps = {
listValues: string[];
onChange: (_values: string[]) => void;
type OptionValue = string | number | boolean | null;
type ComboBoxOption<T = OptionValue> = {
label: string;
value: T;
disabled?: boolean;
};
const Combobox = ({ listValues, onChange }: ComboboxProps) => {
type ComboboxProps<T = OptionValue> = {
emptySelectionPlaceholder?: React.ReactNode | string;
enableClearAllButton?: boolean;
loading?: boolean;
inputPlaceholder?: string;
onChange: (_values: T[]) => void;
options: ComboBoxOption<T>[];
selectedValues: T[];
};
export function Combobox<T = OptionValue>({
emptySelectionPlaceholder = 'Select values...',
enableClearAllButton,
inputPlaceholder,
loading,
onChange,
options,
selectedValues,
}: ComboboxProps<T>) {
const [open, setOpen] = React.useState(false);
const [selectedValues, setSelectedValues] = React.useState<string[]>([]);
const dbRoles = Object.values(Role);
React.useEffect(() => {
setSelectedValues(listValues);
}, [listValues]);
const handleSelect = (selectedOption: T) => {
let newSelectedOptions = [...selectedValues, selectedOption];
const allRoles = [...new Set([...dbRoles, ...selectedValues])];
const handleSelect = (currentValue: string) => {
let newSelectedValues;
if (selectedValues.includes(currentValue)) {
newSelectedValues = selectedValues.filter((value) => value !== currentValue);
} else {
newSelectedValues = [...selectedValues, currentValue];
if (selectedValues.includes(selectedOption)) {
newSelectedOptions = selectedValues.filter((v) => v !== selectedOption);
}
setSelectedValues(newSelectedValues);
onChange(newSelectedValues);
onChange(newSelectedOptions);
setOpen(false);
};
const selectedOptions = React.useMemo(() => {
return selectedValues.map((value): ComboBoxOption<T> => {
const foundOption = options.find((option) => option.value === value);
if (foundOption) {
return foundOption;
}
let label = '';
if (typeof value === 'string' || typeof value === 'number') {
label = value.toString();
}
return {
label,
value,
};
});
}, [selectedValues, options]);
const buttonLabel = React.useMemo(() => {
if (loading) {
return '';
}
if (selectedOptions.length === 0) {
return emptySelectionPlaceholder;
}
return selectedOptions.map((option) => option.label).join(', ');
}, [selectedOptions, emptySelectionPlaceholder, loading]);
return (
<Popover open={open} onOpenChange={setOpen}>
<Popover open={open && !loading} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
disabled={loading}
aria-expanded={open}
className="w-[200px] justify-between"
className="relative w-[200px] px-3"
>
{selectedValues.length > 0 ? selectedValues.join(', ') : 'Select values...'}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
<AnimatePresence>
{loading ? (
<div className="absolute inset-0 flex items-center justify-center">
<Loader className="h-5 w-5 animate-spin text-gray-500 dark:text-gray-100" />
</div>
) : (
<motion.div
className="flex w-full justify-between"
initial={{
opacity: 0,
}}
animate={{
opacity: 1,
}}
exit={{
opacity: 0,
}}
>
<span className="truncate">{buttonLabel}</span>
<div className="ml-2 flex flex-row items-center">
{enableClearAllButton && selectedValues.length > 0 && (
// Todo: Teams - Can't have nested buttons.
<button
className="mr-1 flex h-4 w-4 items-center justify-center rounded-full bg-gray-300"
onClick={(e) => {
e.preventDefault();
onChange([]);
}}
>
<XIcon className="text-muted-foreground h-3.5 w-3.5" />
</button>
)}
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
</div>
</motion.div>
)}
</AnimatePresence>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command>
<CommandInput placeholder={selectedValues.join(', ')} />
<CommandInput placeholder={inputPlaceholder} />
<CommandEmpty>No value found.</CommandEmpty>
<CommandGroup>
{allRoles.map((value: string, i: number) => (
<CommandItem key={i} onSelect={() => handleSelect(value)}>
<Check
className={cn(
'mr-2 h-4 w-4',
selectedValues.includes(value) ? 'opacity-100' : 'opacity-0',
)}
/>
{value}
</CommandItem>
))}
{options.map((option, i) => {
return (
<CommandItem key={i} onSelect={() => handleSelect(option.value)}>
<Check
className={cn(
'mr-2 h-4 w-4',
selectedValues.includes(option.value) ? 'opacity-100' : 'opacity-0',
)}
/>
{typeof option === 'string' ? option : option.label}
</CommandItem>
);
})}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
);
};
export { Combobox };
}

View File

@ -2,36 +2,49 @@
import React, { useMemo } from 'react';
import {
import type {
ColumnDef,
PaginationState,
Table as TTable,
Updater,
flexRender,
getCoreRowModel,
useReactTable,
VisibilityState,
} from '@tanstack/react-table';
import { flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { Skeleton } from './skeleton';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './table';
export type DataTableChildren<TData> = (_table: TTable<TData>) => React.ReactNode;
export interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
columnVisibility?: VisibilityState;
data: TData[];
perPage?: number;
currentPage?: number;
totalPages?: number;
onPaginationChange?: (_page: number, _perPage: number) => void;
children?: DataTableChildren<TData>;
skeleton?: {
enable: boolean;
rows: number;
component?: React.ReactNode;
};
error?: {
enable: boolean;
component?: React.ReactNode;
};
}
export function DataTable<TData, TValue>({
columns,
columnVisibility,
data,
error,
perPage,
currentPage,
totalPages,
skeleton,
onPaginationChange,
children,
}: DataTableProps<TData, TValue>) {
@ -67,6 +80,7 @@ export function DataTable<TData, TValue>({
getCoreRowModel: getCoreRowModel(),
state: {
pagination: manualPagination ? pagination : undefined,
columnVisibility,
},
manualPagination,
pageCount: totalPages,
@ -103,6 +117,18 @@ export function DataTable<TData, TValue>({
))}
</TableRow>
))
) : error?.enable ? (
<TableRow>
{error.component ?? (
<TableCell colSpan={columns.length} className="h-24 text-center">
Something went wrong.
</TableCell>
)}
</TableRow>
) : skeleton?.enable ? (
Array.from({ length: skeleton.rows }).map((_, i) => (
<TableRow key={`skeleton-row-${i}`}>{skeleton.component ?? <Skeleton />}</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">

View File

@ -1,6 +1,9 @@
import * as React from 'react';
import { Eye, EyeOff } from 'lucide-react';
import { cn } from '../lib/utils';
import { Button } from './button';
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
@ -25,4 +28,38 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
Input.displayName = 'Input';
export { Input };
const PasswordInput = React.forwardRef<HTMLInputElement, InputProps>(
({ className, ...props }, ref) => {
const [showPassword, setShowPassword] = React.useState(false);
return (
<div className="relative">
<Input
id="password"
type={showPassword ? 'text' : 'password'}
className={cn('pr-10', className)}
ref={ref}
{...props}
/>
<Button
variant="link"
type="button"
className="absolute right-0 top-0 flex h-full items-center justify-center pr-3"
aria-label={showPassword ? 'Mask password' : 'Reveal password'}
onClick={() => setShowPassword((show) => !show)}
>
{showPassword ? (
<EyeOff aria-hidden className="text-muted-foreground h-5 w-5" />
) : (
<Eye aria-hidden className="text-muted-foreground h-5 w-5" />
)}
</Button>
</div>
);
},
);
PasswordInput.displayName = 'Input';
export { Input, PasswordInput };