feat: add teams (#848)

## Description

Add support for teams which will allow users to collaborate on
documents.

Teams features allows users to:

- Create, manage and transfer teams
- Manage team members
- Manage team emails
- Manage a shared team inbox and documents

These changes do NOT include the following, which are planned for a
future release:

- Team templates
- Team API
- Search menu integration

## Testing Performed

- Added E2E tests for general team management
- Added E2E tests to validate document counts

## Checklist

- [X] I have tested these changes locally and they work as expected.
- [X] I have added/updated tests that prove the effectiveness of these
changes.
- [ ] I have updated the documentation to reflect these changes, if
applicable.
- [X] I have followed the project's coding style guidelines.
This commit is contained in:
David Nguyen
2024-02-06 16:16:10 +11:00
committed by GitHub
parent c6457e75e0
commit 0c339b78b6
200 changed files with 12916 additions and 968 deletions

View File

@ -3,8 +3,10 @@ import type { NextApiRequest, NextApiResponse } from 'next';
import { buffer } from 'micro';
import { match } from 'ts-pattern';
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
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';
@ -84,14 +86,9 @@ export const stripeWebhookHandler = async (
},
});
if (!result?.id) {
return res.status(500).json({
success: false,
message: 'User not found',
});
if (result?.id) {
userId = result.id;
}
userId = result.id;
}
const subscriptionId =
@ -99,7 +96,7 @@ export const stripeWebhookHandler = async (
? session.subscription
: session.subscription?.id;
if (!subscriptionId || Number.isNaN(userId)) {
if (!subscriptionId) {
return res.status(500).json({
success: false,
message: 'Invalid session',
@ -108,6 +105,24 @@ export const stripeWebhookHandler = async (
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
// Handle team creation after seat checkout.
if (subscription.items.data[0].price.metadata.plan === STRIPE_PLAN_TYPE.TEAM) {
await handleTeamSeatCheckout({ subscription });
return res.status(200).json({
success: true,
message: 'Webhook received',
});
}
// Validate user ID.
if (!userId || Number.isNaN(userId)) {
return res.status(500).json({
success: false,
message: 'Invalid session or missing user ID',
});
}
await onSubscriptionUpdated({ userId, subscription });
return res.status(200).json({
@ -124,6 +139,28 @@ export const stripeWebhookHandler = async (
? subscription.customer
: subscription.customer.id;
if (subscription.items.data[0].price.metadata.plan === STRIPE_PLAN_TYPE.TEAM) {
const team = await prisma.team.findFirst({
where: {
customerId,
},
});
if (!team) {
return res.status(500).json({
success: false,
message: 'No team associated with subscription found',
});
}
await onSubscriptionUpdated({ teamId: team.id, subscription });
return res.status(200).json({
success: true,
message: 'Webhook received',
});
}
const result = await prisma.user.findFirst({
select: {
id: true,
@ -182,6 +219,28 @@ export const stripeWebhookHandler = async (
});
}
if (subscription.items.data[0].price.metadata.plan === STRIPE_PLAN_TYPE.TEAM) {
const team = await prisma.team.findFirst({
where: {
customerId,
},
});
if (!team) {
return res.status(500).json({
success: false,
message: 'No team associated with subscription found',
});
}
await onSubscriptionUpdated({ teamId: team.id, subscription });
return res.status(200).json({
success: true,
message: 'Webhook received',
});
}
const result = await prisma.user.findFirst({
select: {
id: true,
@ -233,6 +292,28 @@ export const stripeWebhookHandler = async (
});
}
if (subscription.items.data[0].price.metadata.plan === STRIPE_PLAN_TYPE.TEAM) {
const team = await prisma.team.findFirst({
where: {
customerId,
},
});
if (!team) {
return res.status(500).json({
success: false,
message: 'No team associated with subscription found',
});
}
await onSubscriptionUpdated({ teamId: team.id, subscription });
return res.status(200).json({
success: true,
message: 'Webhook received',
});
}
const result = await prisma.user.findFirst({
select: {
id: true,
@ -282,3 +363,21 @@ export const stripeWebhookHandler = async (
});
}
};
export type HandleTeamSeatCheckoutOptions = {
subscription: Stripe.Subscription;
};
const handleTeamSeatCheckout = async ({ subscription }: HandleTeamSeatCheckoutOptions) => {
if (subscription.metadata?.pendingTeamId === undefined) {
throw new Error('Missing pending team ID');
}
const pendingTeamId = Number(subscription.metadata.pendingTeamId);
if (Number.isNaN(pendingTeamId)) {
throw new Error('Invalid pending team ID');
}
return await createTeamFromPendingTeam({ pendingTeamId, subscription }).then((team) => team.id);
};

View File

@ -2,23 +2,40 @@ import { match } from 'ts-pattern';
import type { Stripe } from '@documenso/lib/server-only/stripe';
import { prisma } from '@documenso/prisma';
import type { Prisma } from '@documenso/prisma/client';
import { SubscriptionStatus } from '@documenso/prisma/client';
export type OnSubscriptionUpdatedOptions = {
userId: number;
userId?: number;
teamId?: number;
subscription: Stripe.Subscription;
};
export const onSubscriptionUpdated = async ({
userId,
teamId,
subscription,
}: OnSubscriptionUpdatedOptions) => {
await prisma.subscription.upsert(
mapStripeSubscriptionToPrismaUpsertAction(subscription, userId, teamId),
);
};
export const mapStripeSubscriptionToPrismaUpsertAction = (
subscription: Stripe.Subscription,
userId?: number,
teamId?: number,
): Prisma.SubscriptionUpsertArgs => {
if ((!userId && !teamId) || (userId && teamId)) {
throw new Error('Either userId or teamId must be provided.');
}
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,
},
@ -27,7 +44,8 @@ export const onSubscriptionUpdated = async ({
planId: subscription.id,
priceId: subscription.items.data[0].price.id,
periodEnd: new Date(subscription.current_period_end * 1000),
userId,
userId: userId ?? null,
teamId: teamId ?? null,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
update: {
@ -37,5 +55,5 @@ export const onSubscriptionUpdated = async ({
periodEnd: new Date(subscription.current_period_end * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
});
};
};