mirror of
https://github.com/documenso/documenso.git
synced 2025-11-18 02:32:00 +10:00
feat: wip
This commit is contained in:
@ -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(),
|
||||
},
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
23
packages/ee/server-only/stripe/get-team-invoices.ts
Normal file
23
packages/ee/server-only/stripe/get-team-invoices.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
96
packages/ee/server-only/stripe/transfer-team-subscription.ts
Normal file
96
packages/ee/server-only/stripe/transfer-team-subscription.ts
Normal 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;
|
||||
};
|
||||
@ -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,
|
||||
})),
|
||||
});
|
||||
};
|
||||
@ -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 });
|
||||
};
|
||||
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
BIN
packages/email/static/add-user.png
Normal file
BIN
packages/email/static/add-user.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
BIN
packages/email/static/mail-open.png
Normal file
BIN
packages/email/static/mail-open.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
17
packages/email/template-components/template-image.tsx
Normal file
17
packages/email/template-components/template-image.tsx
Normal 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;
|
||||
@ -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;
|
||||
|
||||
124
packages/email/templates/confirm-team-email.tsx
Normal file
124
packages/email/templates/confirm-team-email.tsx
Normal 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;
|
||||
107
packages/email/templates/team-invite.tsx
Normal file
107
packages/email/templates/team-invite.tsx
Normal 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;
|
||||
111
packages/email/templates/team-transfer-request.tsx
Normal file
111
packages/email/templates/team-transfer-request.tsx
Normal 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;
|
||||
@ -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';
|
||||
|
||||
34
packages/lib/constants/teams.ts
Normal file
34
packages/lib/constants/teams.ts
Normal 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);
|
||||
};
|
||||
144
packages/lib/errors/app-error.ts
Normal file
144
packages/lib/errors/app-error.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@ -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();
|
||||
};
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) : [],
|
||||
]);
|
||||
};
|
||||
|
||||
@ -26,6 +26,24 @@ export const setFieldsForDocument = async ({
|
||||
where: {
|
||||
id: documentId,
|
||||
userId,
|
||||
OR: [
|
||||
{
|
||||
team: {
|
||||
is: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
team: {
|
||||
is: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -22,6 +22,24 @@ export const setRecipientsForDocument = async ({
|
||||
where: {
|
||||
id: documentId,
|
||||
userId,
|
||||
OR: [
|
||||
{
|
||||
team: {
|
||||
is: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
team: {
|
||||
is: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
60
packages/lib/server-only/team/accept-team-invitation.ts
Normal file
60
packages/lib/server-only/team/accept-team-invitation.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
133
packages/lib/server-only/team/add-team-email-verification.ts
Normal file
133
packages/lib/server-only/team/add-team-email-verification.ts
Normal 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 }),
|
||||
});
|
||||
};
|
||||
@ -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.');
|
||||
}
|
||||
};
|
||||
157
packages/lib/server-only/team/create-team-member-invites.ts
Normal file
157
packages/lib/server-only/team/create-team-member-invites.ts
Normal 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);
|
||||
};
|
||||
200
packages/lib/server-only/team/create-team.ts
Normal file
200
packages/lib/server-only/team/create-team.ts
Normal 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.
|
||||
});
|
||||
});
|
||||
};
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
46
packages/lib/server-only/team/delete-team-email.ts
Normal file
46
packages/lib/server-only/team/delete-team-email.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
47
packages/lib/server-only/team/delete-team-invitations.ts
Normal file
47
packages/lib/server-only/team/delete-team-invitations.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
73
packages/lib/server-only/team/delete-team-members.ts
Normal file
73
packages/lib/server-only/team/delete-team-members.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
15
packages/lib/server-only/team/delete-team-pending.ts
Normal file
15
packages/lib/server-only/team/delete-team-pending.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
39
packages/lib/server-only/team/delete-team.ts
Normal file
39
packages/lib/server-only/team/delete-team.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
48
packages/lib/server-only/team/find-team-invoices.ts
Normal file
48
packages/lib/server-only/team/find-team-invoices.ts
Normal 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,
|
||||
})),
|
||||
};
|
||||
};
|
||||
88
packages/lib/server-only/team/find-team-member-invites.ts
Normal file
88
packages/lib/server-only/team/find-team-member-invites.ts
Normal 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>;
|
||||
};
|
||||
99
packages/lib/server-only/team/find-team-members.ts
Normal file
99
packages/lib/server-only/team/find-team-members.ts
Normal 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>;
|
||||
};
|
||||
58
packages/lib/server-only/team/find-teams-pending.ts
Normal file
58
packages/lib/server-only/team/find-teams-pending.ts
Normal 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),
|
||||
};
|
||||
};
|
||||
77
packages/lib/server-only/team/find-teams.ts
Normal file
77
packages/lib/server-only/team/find-teams.ts
Normal 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>;
|
||||
};
|
||||
22
packages/lib/server-only/team/get-team-email-by-email.ts
Normal file
22
packages/lib/server-only/team/get-team-email-by-email.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
22
packages/lib/server-only/team/get-team-invitations.ts
Normal file
22
packages/lib/server-only/team/get-team-invitations.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
52
packages/lib/server-only/team/get-team-members.ts
Normal file
52
packages/lib/server-only/team/get-team-members.ts
Normal 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;
|
||||
};
|
||||
113
packages/lib/server-only/team/get-teams.ts
Normal file
113
packages/lib/server-only/team/get-teams.ts
Normal 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],
|
||||
};
|
||||
};
|
||||
27
packages/lib/server-only/team/leave-team.ts
Normal file
27
packages/lib/server-only/team/leave-team.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -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 }),
|
||||
});
|
||||
});
|
||||
};
|
||||
@ -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);
|
||||
});
|
||||
};
|
||||
@ -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,
|
||||
});
|
||||
});
|
||||
};
|
||||
86
packages/lib/server-only/team/transfer-team-ownership.ts
Normal file
86
packages/lib/server-only/team/transfer-team-ownership.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
41
packages/lib/server-only/team/update-team-email.ts
Normal file
41
packages/lib/server-only/team/update-team-email.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
50
packages/lib/server-only/team/update-team-member.ts
Normal file
50
packages/lib/server-only/team/update-team-member.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
51
packages/lib/server-only/team/update-team.ts
Normal file
51
packages/lib/server-only/team/update-team.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
@ -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;
|
||||
});
|
||||
};
|
||||
|
||||
20
packages/lib/types/search-params.ts
Normal file
20
packages/lib/types/search-params.ts
Normal 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>;
|
||||
26
packages/lib/utils/billing.ts
Normal file
26
packages/lib/utils/billing.ts
Normal 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;
|
||||
};
|
||||
17
packages/lib/utils/params.ts
Normal file
17
packages/lib/utils/params.ts
Normal 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));
|
||||
};
|
||||
7
packages/lib/utils/teams.ts
Normal file
7
packages/lib/utils/teams.ts
Normal 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}`;
|
||||
};
|
||||
21
packages/lib/utils/token-verification.ts
Normal file
21
packages/lib/utils/token-verification.ts
Normal 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();
|
||||
};
|
||||
156
packages/prisma/migrations/20231227015340_teamwip/migration.sql
Normal file
156
packages/prisma/migrations/20231227015340_teamwip/migration.sql
Normal 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;
|
||||
@ -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])
|
||||
}
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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;
|
||||
|
||||
479
packages/trpc/server/team-router/router.ts
Normal file
479
packages/trpc/server/team-router/router.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
});
|
||||
170
packages/trpc/server/team-router/schema.ts
Normal file
170
packages/trpc/server/team-router/schema.ts
Normal 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
|
||||
>;
|
||||
1
packages/tsconfig/process-env.d.ts
vendored
1
packages/tsconfig/process-env.d.ts
vendored
@ -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;
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user