mirror of
https://github.com/documenso/documenso.git
synced 2025-11-23 05:01:54 +10:00
feat: billing
This commit is contained in:
@ -255,7 +255,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
||||
};
|
||||
}
|
||||
|
||||
const { remaining } = await getServerLimits({ email: user.email, teamId: team?.id });
|
||||
const { remaining } = await getServerLimits({ teamId: team.id });
|
||||
|
||||
if (remaining.documents <= 0) {
|
||||
return {
|
||||
@ -464,7 +464,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
||||
createDocumentFromTemplate: authenticatedMiddleware(async (args, user, team, { metadata }) => {
|
||||
const { body, params } = args;
|
||||
|
||||
const { remaining } = await getServerLimits({ email: user.email, teamId: team?.id });
|
||||
const { remaining } = await getServerLimits({ teamId: team?.id });
|
||||
|
||||
if (remaining.documents <= 0) {
|
||||
return {
|
||||
@ -562,7 +562,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
||||
generateDocumentFromTemplate: authenticatedMiddleware(async (args, user, team, { metadata }) => {
|
||||
const { body, params } = args;
|
||||
|
||||
const { remaining } = await getServerLimits({ email: user.email, teamId: team?.id });
|
||||
const { remaining } = await getServerLimits({ teamId: team?.id });
|
||||
|
||||
if (remaining.documents <= 0) {
|
||||
return {
|
||||
|
||||
@ -48,7 +48,7 @@ test('[TEAMS]: accept team invitation without account', async ({ page }) => {
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/team/invite/${teamInvite.token}`);
|
||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/organisation/invite/${teamInvite.token}`);
|
||||
await expect(page.getByRole('heading')).toContainText('Team invitation');
|
||||
});
|
||||
|
||||
@ -61,7 +61,7 @@ test('[TEAMS]: accept team invitation with account', async ({ page }) => {
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/team/invite/${teamInvite.token}`);
|
||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/organisation/invite/${teamInvite.token}`);
|
||||
await expect(page.getByRole('heading')).toContainText('Invitation accepted!');
|
||||
});
|
||||
|
||||
|
||||
@ -22,7 +22,6 @@ export type SessionUser = Pick<
|
||||
| 'twoFactorEnabled'
|
||||
| 'roles'
|
||||
| 'signature'
|
||||
| 'customerId'
|
||||
>;
|
||||
|
||||
export type SessionValidationResult =
|
||||
@ -98,7 +97,6 @@ export const validateSessionToken = async (token: string): Promise<SessionValida
|
||||
twoFactorEnabled: true,
|
||||
roles: true,
|
||||
signature: true,
|
||||
customerId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@ -5,7 +5,6 @@ import { Hono } from 'hono';
|
||||
import { DateTime } from 'luxon';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { EMAIL_VERIFICATION_STATE } from '@documenso/lib/constants/email';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { jobsClient } from '@documenso/lib/jobs/client';
|
||||
@ -21,7 +20,6 @@ import { getMostRecentVerificationTokenByUserId } from '@documenso/lib/server-on
|
||||
import { resetPassword } from '@documenso/lib/server-only/user/reset-password';
|
||||
import { updatePassword } from '@documenso/lib/server-only/user/update-password';
|
||||
import { verifyEmail } from '@documenso/lib/server-only/user/verify-email';
|
||||
import { alphaid } from '@documenso/lib/universal/id';
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
@ -149,17 +147,9 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
|
||||
});
|
||||
}
|
||||
|
||||
const { name, email, password, signature, url } = c.req.valid('json');
|
||||
const { name, email, password, signature } = c.req.valid('json');
|
||||
|
||||
if (IS_BILLING_ENABLED() && url && url.length < 6) {
|
||||
throw new AppError('PREMIUM_PROFILE_URL', {
|
||||
message: 'Only subscribers can have a username shorter than 6 characters',
|
||||
});
|
||||
}
|
||||
|
||||
const orgUrl = url || alphaid(12);
|
||||
|
||||
const user = await createUser({ name, email, password, signature, orgUrl }).catch((err) => {
|
||||
const user = await createUser({ name, email, password, signature }).catch((err) => {
|
||||
console.error(err);
|
||||
throw err;
|
||||
});
|
||||
|
||||
@ -37,15 +37,6 @@ export const ZSignUpSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: ZPasswordSchema,
|
||||
signature: z.string().nullish(),
|
||||
url: z
|
||||
.string()
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.min(1)
|
||||
.regex(/^[a-z0-9-]+$/, {
|
||||
message: 'Username can only container alphanumeric characters and dashes.',
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type TSignUpSchema = z.infer<typeof ZSignUpSchema>;
|
||||
|
||||
@ -6,7 +6,13 @@ export const FREE_PLAN_LIMITS: TLimitsSchema = {
|
||||
directTemplates: 3,
|
||||
};
|
||||
|
||||
export const TEAM_PLAN_LIMITS: TLimitsSchema = {
|
||||
export const INACTIVE_PLAN_LIMITS: TLimitsSchema = {
|
||||
documents: 0,
|
||||
recipients: 0,
|
||||
directTemplates: 0,
|
||||
};
|
||||
|
||||
export const PAID_PLAN_LIMITS: TLimitsSchema = {
|
||||
documents: Infinity,
|
||||
recipients: Infinity,
|
||||
directTemplates: Infinity,
|
||||
|
||||
@ -17,11 +17,11 @@ export const limitsHandler = async (req: Request) => {
|
||||
teamId = parseInt(rawTeamId, 10);
|
||||
}
|
||||
|
||||
if (!teamId && rawTeamId) {
|
||||
if (!teamId) {
|
||||
throw new Error(ERROR_CODES.INVALID_TEAM_ID);
|
||||
}
|
||||
|
||||
const limits = await getServerLimits({ email: user.email, teamId });
|
||||
const limits = await getServerLimits({ userId: user.id, teamId });
|
||||
|
||||
return Response.json(limits, {
|
||||
status: 200,
|
||||
|
||||
@ -2,21 +2,25 @@ import { DocumentSource, SubscriptionStatus } from '@prisma/client';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { getDocumentRelatedPrices } from '../stripe/get-document-related-prices.ts';
|
||||
import { FREE_PLAN_LIMITS, SELFHOSTED_PLAN_LIMITS, TEAM_PLAN_LIMITS } from './constants';
|
||||
import {
|
||||
FREE_PLAN_LIMITS,
|
||||
INACTIVE_PLAN_LIMITS,
|
||||
PAID_PLAN_LIMITS,
|
||||
SELFHOSTED_PLAN_LIMITS,
|
||||
} from './constants';
|
||||
import { ERROR_CODES } from './errors';
|
||||
import type { TLimitsResponseSchema } from './schema';
|
||||
import { ZLimitsSchema } from './schema';
|
||||
|
||||
export type GetServerLimitsOptions = {
|
||||
email: string;
|
||||
teamId: number | null;
|
||||
userId: number;
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
export const getServerLimits = async ({
|
||||
email,
|
||||
userId,
|
||||
teamId,
|
||||
}: GetServerLimitsOptions): Promise<TLimitsResponseSchema> => {
|
||||
if (!IS_BILLING_ENABLED()) {
|
||||
@ -26,141 +30,89 @@ export const getServerLimits = async ({
|
||||
};
|
||||
}
|
||||
|
||||
if (!email) {
|
||||
throw new Error(ERROR_CODES.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
return teamId ? handleTeamLimits({ email, teamId }) : handleUserLimits({ email });
|
||||
};
|
||||
|
||||
type HandleUserLimitsOptions = {
|
||||
email: string;
|
||||
};
|
||||
|
||||
const handleUserLimits = async ({ email }: HandleUserLimitsOptions) => {
|
||||
const user = await prisma.user.findFirst({
|
||||
const organisation = await prisma.organisation.findFirst({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
include: {
|
||||
subscriptions: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error(ERROR_CODES.USER_FETCH_FAILED);
|
||||
}
|
||||
|
||||
let quota = structuredClone(FREE_PLAN_LIMITS);
|
||||
let remaining = structuredClone(FREE_PLAN_LIMITS);
|
||||
|
||||
const activeSubscriptions = user.subscriptions.filter(
|
||||
({ status }) => status === SubscriptionStatus.ACTIVE,
|
||||
);
|
||||
|
||||
if (activeSubscriptions.length > 0) {
|
||||
const documentPlanPrices = await getDocumentRelatedPrices();
|
||||
|
||||
for (const subscription of activeSubscriptions) {
|
||||
const price = documentPlanPrices.find((price) => price.id === subscription.priceId);
|
||||
|
||||
if (!price || typeof price.product === 'string' || price.product.deleted) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentQuota = ZLimitsSchema.parse(
|
||||
'metadata' in price.product ? price.product.metadata : {},
|
||||
);
|
||||
|
||||
// Use the subscription with the highest quota.
|
||||
if (currentQuota.documents > quota.documents && currentQuota.recipients > quota.recipients) {
|
||||
quota = currentQuota;
|
||||
remaining = structuredClone(quota);
|
||||
}
|
||||
}
|
||||
|
||||
// Assume all active subscriptions provide unlimited direct templates.
|
||||
remaining.directTemplates = Infinity;
|
||||
}
|
||||
|
||||
const [documents, directTemplates] = await Promise.all([
|
||||
prisma.document.count({
|
||||
where: {
|
||||
userId: user.id,
|
||||
teamId: null,
|
||||
createdAt: {
|
||||
gte: DateTime.utc().startOf('month').toJSDate(),
|
||||
},
|
||||
source: {
|
||||
not: DocumentSource.TEMPLATE_DIRECT_LINK,
|
||||
teams: {
|
||||
some: {
|
||||
id: teamId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.template.count({
|
||||
where: {
|
||||
userId: user.id,
|
||||
teamId: null,
|
||||
directLink: {
|
||||
isNot: null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
remaining.documents = Math.max(remaining.documents - documents, 0);
|
||||
remaining.directTemplates = Math.max(remaining.directTemplates - directTemplates, 0);
|
||||
|
||||
return {
|
||||
quota,
|
||||
remaining,
|
||||
};
|
||||
};
|
||||
|
||||
type HandleTeamLimitsOptions = {
|
||||
email: string;
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
const handleTeamLimits = async ({ email, teamId }: HandleTeamLimitsOptions) => {
|
||||
const team = await prisma.team.findFirst({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
user: {
|
||||
email,
|
||||
},
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
subscription: true,
|
||||
organisationClaim: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
throw new Error('Team not found');
|
||||
if (!organisation) {
|
||||
throw new Error(ERROR_CODES.USER_FETCH_FAILED);
|
||||
}
|
||||
|
||||
const { subscription } = team;
|
||||
const quota = structuredClone(FREE_PLAN_LIMITS);
|
||||
const remaining = structuredClone(FREE_PLAN_LIMITS);
|
||||
|
||||
if (subscription && subscription.status === SubscriptionStatus.INACTIVE) {
|
||||
const subscription = organisation.subscription;
|
||||
|
||||
// Bypass all limits even if plan expired for ENTERPRISE.
|
||||
if (organisation.organisationClaimId === INTERNAL_CLAIM_ID.ENTERPRISE) {
|
||||
return {
|
||||
quota: {
|
||||
documents: 0,
|
||||
recipients: 0,
|
||||
directTemplates: 0,
|
||||
},
|
||||
remaining: {
|
||||
documents: 0,
|
||||
recipients: 0,
|
||||
directTemplates: 0,
|
||||
},
|
||||
quota: PAID_PLAN_LIMITS,
|
||||
remaining: PAID_PLAN_LIMITS,
|
||||
};
|
||||
}
|
||||
|
||||
// If free tier or plan does not have unlimited documents.
|
||||
if (!subscription || !organisation.organisationClaim.flags.unlimitedDocuments) {
|
||||
const [documents, directTemplates] = await Promise.all([
|
||||
prisma.document.count({
|
||||
where: {
|
||||
team: {
|
||||
organisationId: organisation.id,
|
||||
},
|
||||
createdAt: {
|
||||
gte: DateTime.utc().startOf('month').toJSDate(),
|
||||
},
|
||||
source: {
|
||||
not: DocumentSource.TEMPLATE_DIRECT_LINK,
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.template.count({
|
||||
where: {
|
||||
team: {
|
||||
organisationId: organisation.id,
|
||||
},
|
||||
directLink: {
|
||||
isNot: null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
remaining.documents = Math.max(remaining.documents - documents, 0);
|
||||
remaining.directTemplates = Math.max(remaining.directTemplates - directTemplates, 0);
|
||||
|
||||
return {
|
||||
quota,
|
||||
remaining,
|
||||
};
|
||||
}
|
||||
|
||||
// If plan expired.
|
||||
if (subscription.status !== SubscriptionStatus.ACTIVE) {
|
||||
return {
|
||||
quota: INACTIVE_PLAN_LIMITS,
|
||||
remaining: INACTIVE_PLAN_LIMITS,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
quota: structuredClone(TEAM_PLAN_LIMITS),
|
||||
remaining: structuredClone(TEAM_PLAN_LIMITS),
|
||||
quota: PAID_PLAN_LIMITS,
|
||||
remaining: PAID_PLAN_LIMITS,
|
||||
};
|
||||
};
|
||||
|
||||
@ -1,20 +1,23 @@
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
|
||||
export type GetCheckoutSessionOptions = {
|
||||
export type CreateCheckoutSessionOptions = {
|
||||
customerId: string;
|
||||
priceId: string;
|
||||
returnUrl: string;
|
||||
subscriptionMetadata?: Stripe.Metadata;
|
||||
};
|
||||
|
||||
export const getCheckoutSession = async ({
|
||||
// Todo: orgs validate priceId to ensure it's only ones we allow
|
||||
|
||||
export const createCheckoutSession = async ({
|
||||
customerId,
|
||||
priceId,
|
||||
returnUrl,
|
||||
subscriptionMetadata,
|
||||
}: GetCheckoutSessionOptions) => {
|
||||
}: CreateCheckoutSessionOptions) => {
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
customer: customerId,
|
||||
mode: 'subscription',
|
||||
@ -31,5 +34,11 @@ export const getCheckoutSession = async ({
|
||||
},
|
||||
});
|
||||
|
||||
if (!session.url) {
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||
message: 'Failed to create checkout session',
|
||||
});
|
||||
}
|
||||
|
||||
return session.url;
|
||||
};
|
||||
13
packages/ee/server-only/stripe/create-customer.ts
Normal file
13
packages/ee/server-only/stripe/create-customer.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
|
||||
type CreateCustomerOptions = {
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
export const createCustomer = async ({ name, email }: CreateCustomerOptions) => {
|
||||
return await stripe.customers.create({
|
||||
name,
|
||||
email,
|
||||
});
|
||||
};
|
||||
@ -1,23 +0,0 @@
|
||||
import { STRIPE_CUSTOMER_TYPE } from '@documenso/lib/constants/billing';
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
|
||||
type CreateOrganisationCustomerOptions = {
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a Stripe customer for a given Organisation.
|
||||
*/
|
||||
export const createOrganisationCustomer = async ({
|
||||
name,
|
||||
email,
|
||||
}: CreateOrganisationCustomerOptions) => {
|
||||
return await stripe.customers.create({
|
||||
name,
|
||||
email,
|
||||
metadata: {
|
||||
type: STRIPE_CUSTOMER_TYPE.ORGANISATION,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -1,22 +0,0 @@
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
|
||||
type DeleteCustomerPaymentMethodsOptions = {
|
||||
customerId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete all attached payment methods for a given customer.
|
||||
*/
|
||||
export const deleteCustomerPaymentMethods = async ({
|
||||
customerId,
|
||||
}: DeleteCustomerPaymentMethodsOptions) => {
|
||||
const paymentMethods = await stripe.paymentMethods.list({
|
||||
customer: customerId,
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
paymentMethods.data.map(async (paymentMethod) =>
|
||||
stripe.paymentMethods.detach(paymentMethod.id),
|
||||
),
|
||||
);
|
||||
};
|
||||
@ -1,13 +0,0 @@
|
||||
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
||||
|
||||
import { getPricesByPlan } from './get-prices-by-plan';
|
||||
|
||||
export const getCommunityPlanPrices = async () => {
|
||||
return await getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY);
|
||||
};
|
||||
|
||||
export const getCommunityPlanPriceIds = async () => {
|
||||
const prices = await getCommunityPlanPrices();
|
||||
|
||||
return prices.map((price) => price.id);
|
||||
};
|
||||
@ -1,21 +1,4 @@
|
||||
import type { User } from '@prisma/client';
|
||||
|
||||
import { STRIPE_CUSTOMER_TYPE } from '@documenso/lib/constants/billing';
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { onSubscriptionUpdated } from './webhook/on-subscription-updated';
|
||||
|
||||
/**
|
||||
* Get a non team Stripe customer by email.
|
||||
*/
|
||||
export const getStripeCustomerByEmail = async (email: string) => {
|
||||
const foundStripeCustomers = await stripe.customers.list({
|
||||
email,
|
||||
});
|
||||
|
||||
return foundStripeCustomers.data.find((customer) => customer.metadata.type !== 'team') ?? null;
|
||||
};
|
||||
|
||||
export const getStripeCustomerById = async (stripeCustomerId: string) => {
|
||||
try {
|
||||
@ -26,86 +9,3 @@ export const getStripeCustomerById = async (stripeCustomerId: string) => {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Todo: (orgs)
|
||||
/**
|
||||
* Get a stripe customer by user.
|
||||
*
|
||||
* Will create a Stripe customer and update the relevant user if one does not exist.
|
||||
*/
|
||||
export const getStripeCustomerByUser = async (
|
||||
user: Pick<User, 'id' | 'customerId' | 'email' | 'name'>,
|
||||
) => {
|
||||
if (user.customerId) {
|
||||
const stripeCustomer = await getStripeCustomerById(user.customerId);
|
||||
|
||||
if (!stripeCustomer) {
|
||||
throw new Error('Missing Stripe customer');
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
stripeCustomer,
|
||||
};
|
||||
}
|
||||
|
||||
let stripeCustomer = await getStripeCustomerByEmail(user.email);
|
||||
|
||||
const isSyncRequired = Boolean(stripeCustomer && !stripeCustomer.deleted);
|
||||
|
||||
if (!stripeCustomer) {
|
||||
stripeCustomer = await stripe.customers.create({
|
||||
name: user.name ?? undefined,
|
||||
email: user.email,
|
||||
metadata: {
|
||||
userId: user.id,
|
||||
type: STRIPE_CUSTOMER_TYPE.INDIVIDUAL,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
customerId: stripeCustomer.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Sync subscriptions if the customer already exists for back filling the DB
|
||||
// and local development.
|
||||
if (isSyncRequired) {
|
||||
await syncStripeCustomerSubscriptions(user.id, stripeCustomer.id).catch((e) => {
|
||||
console.error(e);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
user: updatedUser,
|
||||
stripeCustomer,
|
||||
};
|
||||
};
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
stripeSubscriptions.data.map(async (subscription) =>
|
||||
onSubscriptionUpdated({
|
||||
userId,
|
||||
subscription,
|
||||
}),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,15 +0,0 @@
|
||||
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
||||
|
||||
import { getPricesByPlan } from './get-prices-by-plan';
|
||||
|
||||
/**
|
||||
* Returns the Stripe prices of items that affect the amount of documents a user can create.
|
||||
*/
|
||||
export const getDocumentRelatedPrices = async () => {
|
||||
return await getPricesByPlan([
|
||||
STRIPE_PLAN_TYPE.REGULAR,
|
||||
STRIPE_PLAN_TYPE.COMMUNITY,
|
||||
STRIPE_PLAN_TYPE.PLATFORM,
|
||||
STRIPE_PLAN_TYPE.ENTERPRISE,
|
||||
]);
|
||||
};
|
||||
@ -1,13 +0,0 @@
|
||||
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
||||
|
||||
import { getPricesByPlan } from './get-prices-by-plan';
|
||||
|
||||
export const getEnterprisePlanPrices = async () => {
|
||||
return await getPricesByPlan(STRIPE_PLAN_TYPE.ENTERPRISE);
|
||||
};
|
||||
|
||||
export const getEnterprisePlanPriceIds = async () => {
|
||||
const prices = await getEnterprisePlanPrices();
|
||||
|
||||
return prices.map((price) => price.id);
|
||||
};
|
||||
88
packages/ee/server-only/stripe/get-internal-claim-plans.ts
Normal file
88
packages/ee/server-only/stripe/get-internal-claim-plans.ts
Normal file
@ -0,0 +1,88 @@
|
||||
import { clone } from 'remeda';
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
import {
|
||||
INTERNAL_CLAIM_ID,
|
||||
type InternalClaim,
|
||||
internalClaims,
|
||||
} from '@documenso/lib/types/subscription';
|
||||
import { toHumanPrice } from '@documenso/lib/universal/stripe/to-human-price';
|
||||
|
||||
export type InternalClaimPlans = {
|
||||
[key in INTERNAL_CLAIM_ID]: InternalClaim & {
|
||||
monthlyPrice?: Stripe.Price & {
|
||||
product: Stripe.Product;
|
||||
isVisibleInApp: boolean;
|
||||
friendlyPrice: string;
|
||||
};
|
||||
yearlyPrice?: Stripe.Price & {
|
||||
product: Stripe.Product;
|
||||
isVisibleInApp: boolean;
|
||||
friendlyPrice: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the main Documenso plans from Stripe.
|
||||
*/
|
||||
export const getInternalClaimPlans = async (): Promise<InternalClaimPlans> => {
|
||||
const { data: prices } = await stripe.prices.search({
|
||||
query: `active:'true' type:'recurring'`,
|
||||
expand: ['data.product'],
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
const plans: InternalClaimPlans = clone(internalClaims);
|
||||
|
||||
prices.forEach((price) => {
|
||||
// We use `expand` to get the product, but it's not typed as part of the Price type.
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const product = price.product as Stripe.Product;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const productClaimId = product.metadata.claimId as INTERNAL_CLAIM_ID | undefined;
|
||||
const isVisibleInApp = price.metadata.visibleInApp === 'true';
|
||||
|
||||
if (!productClaimId || !Object.values(INTERNAL_CLAIM_ID).includes(productClaimId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (product.name.includes('Team')) {
|
||||
console.log(JSON.stringify(price, null, 2));
|
||||
}
|
||||
|
||||
let usdPrice = toHumanPrice(price.unit_amount ?? 0);
|
||||
|
||||
if (price.recurring?.interval === 'month') {
|
||||
if (product.metadata['isSeatBased'] === 'true') {
|
||||
usdPrice = '50';
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
plans[productClaimId].monthlyPrice = {
|
||||
...price,
|
||||
isVisibleInApp,
|
||||
product,
|
||||
friendlyPrice: `$${usdPrice} ${price.currency.toUpperCase()}`.replace('.00', ''),
|
||||
};
|
||||
}
|
||||
|
||||
if (price.recurring?.interval === 'year') {
|
||||
if (product.metadata['isSeatBased'] === 'true') {
|
||||
usdPrice = '480';
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
plans[productClaimId].yearlyPrice = {
|
||||
...price,
|
||||
isVisibleInApp,
|
||||
product,
|
||||
friendlyPrice: `$${usdPrice} ${price.currency.toUpperCase()}`.replace('.00', ''),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return plans;
|
||||
};
|
||||
@ -1,13 +0,0 @@
|
||||
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
||||
|
||||
import { getPricesByPlan } from './get-prices-by-plan';
|
||||
|
||||
export const getPlatformPlanPrices = async () => {
|
||||
return await getPricesByPlan(STRIPE_PLAN_TYPE.PLATFORM);
|
||||
};
|
||||
|
||||
export const getPlatformPlanPriceIds = async () => {
|
||||
const prices = await getPlatformPlanPrices();
|
||||
|
||||
return prices.map((price) => price.id);
|
||||
};
|
||||
@ -1,60 +0,0 @@
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
import type { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
|
||||
// Utility type to handle usage of the `expand` option.
|
||||
type PriceWithProduct = Stripe.Price & { product: Stripe.Product };
|
||||
|
||||
export type PriceIntervals = Record<Stripe.Price.Recurring.Interval, PriceWithProduct[]>;
|
||||
|
||||
export type GetPricesByIntervalOptions = {
|
||||
/**
|
||||
* Filter products by their meta 'plan' attribute.
|
||||
*/
|
||||
plans?: STRIPE_PLAN_TYPE[];
|
||||
};
|
||||
|
||||
export const getPricesByInterval = async ({ plans }: GetPricesByIntervalOptions = {}) => {
|
||||
let { data: prices } = await stripe.prices.search({
|
||||
query: `active:'true' type:'recurring'`,
|
||||
expand: ['data.product'],
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
prices = prices.filter((price) => {
|
||||
// We use `expand` to get the product, but it's not typed as part of the Price type.
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const product = price.product as Stripe.Product;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const filter = !plans || plans.includes(product.metadata?.plan as STRIPE_PLAN_TYPE);
|
||||
|
||||
// Filter out prices for products that are not active.
|
||||
return product.active && filter;
|
||||
});
|
||||
|
||||
const intervals: PriceIntervals = {
|
||||
day: [],
|
||||
week: [],
|
||||
month: [],
|
||||
year: [],
|
||||
};
|
||||
|
||||
// Add each price to the correct interval.
|
||||
for (const price of prices) {
|
||||
if (price.recurring?.interval) {
|
||||
// We use `expand` to get the product, but it's not typed as part of the Price type.
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
intervals[price.recurring.interval].push(price as PriceWithProduct);
|
||||
}
|
||||
}
|
||||
|
||||
// Order all prices by unit_amount.
|
||||
intervals.day.sort((a, b) => Number(a.unit_amount) - Number(b.unit_amount));
|
||||
intervals.week.sort((a, b) => Number(a.unit_amount) - Number(b.unit_amount));
|
||||
intervals.month.sort((a, b) => Number(a.unit_amount) - Number(b.unit_amount));
|
||||
intervals.year.sort((a, b) => Number(a.unit_amount) - Number(b.unit_amount));
|
||||
|
||||
return intervals;
|
||||
};
|
||||
@ -1,17 +0,0 @@
|
||||
import type { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
|
||||
type PlanType = (typeof STRIPE_PLAN_TYPE)[keyof typeof STRIPE_PLAN_TYPE];
|
||||
|
||||
export const getPricesByPlan = async (plan: PlanType | PlanType[]) => {
|
||||
const planTypes: string[] = typeof plan === 'string' ? [plan] : plan;
|
||||
|
||||
const prices = await stripe.prices.list({
|
||||
expand: ['data.product'],
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
return prices.data.filter(
|
||||
(price) => price.type === 'recurring' && planTypes.includes(price.metadata.plan),
|
||||
);
|
||||
};
|
||||
@ -1,15 +0,0 @@
|
||||
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
||||
|
||||
import { getPricesByPlan } from './get-prices-by-plan';
|
||||
|
||||
/**
|
||||
* Returns the prices of items that count as the account's primary plan.
|
||||
*/
|
||||
export const getPrimaryAccountPlanPrices = async () => {
|
||||
return await getPricesByPlan([
|
||||
STRIPE_PLAN_TYPE.REGULAR,
|
||||
STRIPE_PLAN_TYPE.COMMUNITY,
|
||||
STRIPE_PLAN_TYPE.PLATFORM,
|
||||
STRIPE_PLAN_TYPE.ENTERPRISE,
|
||||
]);
|
||||
};
|
||||
@ -1,17 +0,0 @@
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
|
||||
export type GetProductByPriceIdOptions = {
|
||||
priceId: string;
|
||||
};
|
||||
|
||||
export const getProductByPriceId = async ({ priceId }: GetProductByPriceIdOptions) => {
|
||||
const { product } = await stripe.prices.retrieve(priceId, {
|
||||
expand: ['product'],
|
||||
});
|
||||
|
||||
if (typeof product === 'string' || 'deleted' in product) {
|
||||
throw new Error('Product not found');
|
||||
}
|
||||
|
||||
return product;
|
||||
};
|
||||
42
packages/ee/server-only/stripe/get-subscription.ts
Normal file
42
packages/ee/server-only/stripe/get-subscription.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type GetSubscriptionOptions = {
|
||||
userId: number;
|
||||
organisationId: string;
|
||||
};
|
||||
|
||||
export const getSubscription = async ({ organisationId, userId }: GetSubscriptionOptions) => {
|
||||
const organisation = await prisma.organisation.findFirst({
|
||||
where: buildOrganisationWhereQuery(
|
||||
organisationId,
|
||||
userId,
|
||||
ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
|
||||
),
|
||||
include: {
|
||||
subscription: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!organisation) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Organisation not found',
|
||||
});
|
||||
}
|
||||
|
||||
if (!organisation.subscription) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stripeSubscription = await stripe.subscriptions.retrieve(organisation.subscription.planId, {
|
||||
expand: ['items.data.price.product'],
|
||||
});
|
||||
|
||||
return {
|
||||
organisationSubscription: organisation.subscription,
|
||||
stripeSubscription,
|
||||
};
|
||||
};
|
||||
@ -1,45 +0,0 @@
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
|
||||
import { getPricesByPlan } from './get-prices-by-plan';
|
||||
|
||||
export const getTeamPrices = async () => {
|
||||
const prices = (await getPricesByPlan(STRIPE_PLAN_TYPE.TEAM)).filter((price) => price.active);
|
||||
|
||||
const monthlyPrice = prices.find((price) => price.recurring?.interval === 'month');
|
||||
const yearlyPrice = prices.find((price) => price.recurring?.interval === 'year');
|
||||
const priceIds = prices.map((price) => price.id);
|
||||
|
||||
if (!monthlyPrice || !yearlyPrice) {
|
||||
throw new AppError('INVALID_CONFIG', {
|
||||
message: 'Missing monthly or yearly price',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
monthly: {
|
||||
friendlyInterval: 'Monthly',
|
||||
interval: 'monthly',
|
||||
...extractPriceData(monthlyPrice),
|
||||
},
|
||||
yearly: {
|
||||
friendlyInterval: 'Yearly',
|
||||
interval: 'yearly',
|
||||
...extractPriceData(yearlyPrice),
|
||||
},
|
||||
priceIds,
|
||||
} as const;
|
||||
};
|
||||
|
||||
const extractPriceData = (price: Stripe.Price) => {
|
||||
const product =
|
||||
typeof price.product !== 'string' && !price.product.deleted ? price.product : null;
|
||||
|
||||
return {
|
||||
priceId: price.id,
|
||||
description: product?.description ?? '',
|
||||
features: product?.features ?? [],
|
||||
};
|
||||
};
|
||||
@ -1,21 +0,0 @@
|
||||
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
||||
|
||||
import { getPricesByPlan } from './get-prices-by-plan';
|
||||
|
||||
/**
|
||||
* Returns the Stripe prices of items that affect the amount of teams a user can create.
|
||||
*/
|
||||
export const getTeamRelatedPrices = async () => {
|
||||
return await getPricesByPlan([
|
||||
STRIPE_PLAN_TYPE.COMMUNITY,
|
||||
STRIPE_PLAN_TYPE.PLATFORM,
|
||||
STRIPE_PLAN_TYPE.ENTERPRISE,
|
||||
]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the Stripe price IDs of items that affect the amount of teams a user can create.
|
||||
*/
|
||||
export const getTeamRelatedPriceIds = async () => {
|
||||
return await getTeamRelatedPrices().then((prices) => prices.map((price) => price.id));
|
||||
};
|
||||
13
packages/ee/server-only/stripe/is-price-seats-based.ts
Normal file
13
packages/ee/server-only/stripe/is-price-seats-based.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import type { Stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
|
||||
export const isPriceSeatsBased = async (priceId: string) => {
|
||||
const foundStripePrice = await stripe.prices.retrieve(priceId, {
|
||||
expand: ['product'],
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const product = foundStripePrice.product as Stripe.Product;
|
||||
|
||||
return product.metadata.isSeatBased === 'true';
|
||||
};
|
||||
@ -1,6 +1,12 @@
|
||||
import type { OrganisationClaim, Subscription } from '@prisma/client';
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { appLog } from '@documenso/lib/utils/debugger';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { isPriceSeatsBased } from './is-price-seats-based';
|
||||
|
||||
export type UpdateSubscriptionItemQuantityOptions = {
|
||||
subscriptionId: string;
|
||||
@ -42,3 +48,57 @@ export const updateSubscriptionItemQuantity = async ({
|
||||
|
||||
await stripe.subscriptions.update(subscriptionId, subscriptionUpdatePayload);
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks whether the member count should be synced with a given Stripe subscription.
|
||||
*
|
||||
* If the subscription is not "seat" based, it will be ignored.
|
||||
*
|
||||
* @param subscription - The subscription to sync the member count with.
|
||||
* @param organisationClaim - The organisation claim
|
||||
* @param quantity - The amount to sync the Stripe item with
|
||||
* @returns
|
||||
*/
|
||||
export const syncMemberCountWithStripeSeatPlan = async (
|
||||
subscription: Subscription,
|
||||
organisationClaim: OrganisationClaim,
|
||||
quantity: number,
|
||||
) => {
|
||||
const maximumMemberCount = organisationClaim.memberCount;
|
||||
|
||||
// Infinite seats means no sync needed.
|
||||
if (maximumMemberCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const syncMemberCountWithStripe = await isPriceSeatsBased(subscription.priceId);
|
||||
|
||||
// Throw error if quantity exceeds maximum member count and the subscription is not seats based.
|
||||
if (quantity > maximumMemberCount && !syncMemberCountWithStripe) {
|
||||
throw new AppError(AppErrorCode.LIMIT_EXCEEDED, {
|
||||
message: 'Maximum member count reached',
|
||||
});
|
||||
}
|
||||
|
||||
// Bill the user with the new quantity.
|
||||
if (syncMemberCountWithStripe) {
|
||||
appLog('BILLING', 'Updating seat based plan');
|
||||
|
||||
await updateSubscriptionItemQuantity({
|
||||
priceId: subscription.priceId,
|
||||
subscriptionId: subscription.planId,
|
||||
quantity,
|
||||
});
|
||||
|
||||
// This should be automatically updated after the Stripe webhook is fired
|
||||
// but we just manually adjust it here as well to avoid any race conditions.
|
||||
await prisma.organisationClaim.update({
|
||||
where: {
|
||||
id: organisationClaim.id,
|
||||
},
|
||||
data: {
|
||||
memberCount: quantity,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@ -1,13 +1,11 @@
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
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 { env } from '@documenso/lib/utils/env';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { onSubscriptionCreated } from './on-subscription-created';
|
||||
import { onSubscriptionDeleted } from './on-subscription-deleted';
|
||||
import { onSubscriptionUpdated } from './on-subscription-updated';
|
||||
|
||||
@ -65,78 +63,18 @@ export const stripeWebhookHandler = async (req: Request): Promise<Response> => {
|
||||
|
||||
const event = stripe.webhooks.constructEvent(payload, signature, webhookSecret);
|
||||
|
||||
/**
|
||||
* Notes:
|
||||
* - Dropped invoice.payment_succeeded
|
||||
* - Dropped invoice.payment_failed
|
||||
* - Dropped checkout-session.completed
|
||||
*/
|
||||
return await match(event.type)
|
||||
.with('checkout.session.completed', async () => {
|
||||
.with('customer.subscription.created', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const session = event.data.object as Stripe.Checkout.Session;
|
||||
const subscription = event.data.object as Stripe.Subscription;
|
||||
|
||||
const customerId =
|
||||
typeof session.customer === 'string' ? session.customer : session.customer?.id;
|
||||
|
||||
// Attempt to get the user ID from the client reference id.
|
||||
let userId = Number(session.client_reference_id);
|
||||
|
||||
// If the user ID is not found, attempt to get it from the Stripe customer metadata.
|
||||
if (!userId && customerId) {
|
||||
const customer = await stripe.customers.retrieve(customerId);
|
||||
|
||||
if (!customer.deleted) {
|
||||
userId = Number(customer.metadata.userId);
|
||||
}
|
||||
}
|
||||
|
||||
// Finally, attempt to get the user ID from the subscription within the database.
|
||||
if (!userId && customerId) {
|
||||
const result = await prisma.user.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
where: {
|
||||
customerId,
|
||||
},
|
||||
});
|
||||
|
||||
if (result?.id) {
|
||||
userId = result.id;
|
||||
}
|
||||
}
|
||||
|
||||
const subscriptionId =
|
||||
typeof session.subscription === 'string'
|
||||
? session.subscription
|
||||
: session.subscription?.id;
|
||||
|
||||
if (!subscriptionId) {
|
||||
return Response.json(
|
||||
{ success: false, message: 'Invalid session' } satisfies StripeWebhookResponse,
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
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 Response.json(
|
||||
{ success: true, message: 'Webhook received' } satisfies StripeWebhookResponse,
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate user ID.
|
||||
if (!userId || Number.isNaN(userId)) {
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Invalid session or missing user ID',
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
await onSubscriptionUpdated({ userId, subscription });
|
||||
await onSubscriptionCreated({ subscription });
|
||||
|
||||
return Response.json(
|
||||
{ success: true, message: 'Webhook received' } satisfies StripeWebhookResponse,
|
||||
@ -147,254 +85,14 @@ export const stripeWebhookHandler = async (req: Request): Promise<Response> => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const subscription = event.data.object as Stripe.Subscription;
|
||||
|
||||
const customerId =
|
||||
typeof subscription.customer === 'string'
|
||||
? 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 Response.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'No team associated with subscription found',
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
await onSubscriptionUpdated({ teamId: team.id, subscription });
|
||||
|
||||
return Response.json(
|
||||
{ success: true, message: 'Webhook received' } satisfies StripeWebhookResponse,
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
const result = await prisma.user.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
where: {
|
||||
customerId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!result?.id) {
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'User not found',
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
await onSubscriptionUpdated({ userId: result.id, subscription });
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
success: true,
|
||||
message: 'Webhook received',
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 200 },
|
||||
);
|
||||
})
|
||||
.with('invoice.payment_succeeded', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const invoice = event.data.object as Stripe.Invoice;
|
||||
const previousAttributes = event.data
|
||||
.previous_attributes as Partial<Stripe.Subscription> | null;
|
||||
|
||||
if (invoice.billing_reason !== 'subscription_cycle') {
|
||||
return Response.json(
|
||||
{
|
||||
success: true,
|
||||
message: 'Webhook received',
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
const customerId =
|
||||
typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id;
|
||||
|
||||
const subscriptionId =
|
||||
typeof invoice.subscription === 'string'
|
||||
? invoice.subscription
|
||||
: invoice.subscription?.id;
|
||||
|
||||
if (!customerId || !subscriptionId) {
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Invalid invoice',
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
||||
|
||||
if (subscription.status === 'incomplete_expired') {
|
||||
return Response.json(
|
||||
{
|
||||
success: true,
|
||||
message: 'Webhook received',
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
if (subscription.items.data[0].price.metadata.plan === STRIPE_PLAN_TYPE.TEAM) {
|
||||
const team = await prisma.team.findFirst({
|
||||
where: {
|
||||
customerId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'No team associated with subscription found',
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
await onSubscriptionUpdated({ teamId: team.id, subscription });
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
success: true,
|
||||
message: 'Webhook received',
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
const result = await prisma.user.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
where: {
|
||||
customerId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!result?.id) {
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'User not found',
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
await onSubscriptionUpdated({ userId: result.id, subscription });
|
||||
await onSubscriptionUpdated({ subscription, previousAttributes });
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
success: true,
|
||||
message: 'Webhook received',
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 200 },
|
||||
);
|
||||
})
|
||||
.with('invoice.payment_failed', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const invoice = event.data.object as Stripe.Invoice;
|
||||
|
||||
const customerId =
|
||||
typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id;
|
||||
|
||||
const subscriptionId =
|
||||
typeof invoice.subscription === 'string'
|
||||
? invoice.subscription
|
||||
: invoice.subscription?.id;
|
||||
|
||||
if (!customerId || !subscriptionId) {
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Invalid invoice',
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
||||
|
||||
if (subscription.status === 'incomplete_expired') {
|
||||
return Response.json(
|
||||
{
|
||||
success: true,
|
||||
message: 'Webhook received',
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
if (subscription.items.data[0].price.metadata.plan === STRIPE_PLAN_TYPE.TEAM) {
|
||||
const team = await prisma.team.findFirst({
|
||||
where: {
|
||||
customerId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'No team associated with subscription found',
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
await onSubscriptionUpdated({ teamId: team.id, subscription });
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
success: true,
|
||||
message: 'Webhook received',
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
const result = await prisma.user.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
where: {
|
||||
customerId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!result?.id) {
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'User not found',
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
await onSubscriptionUpdated({ userId: result.id, subscription });
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
success: true,
|
||||
message: 'Webhook received',
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ success: true, message: 'Webhook received' } satisfies StripeWebhookResponse,
|
||||
{ status: 200 },
|
||||
);
|
||||
})
|
||||
@ -424,6 +122,13 @@ export const stripeWebhookHandler = async (req: Request): Promise<Response> => {
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
if (err instanceof Response) {
|
||||
const message = await err.json();
|
||||
console.error(message);
|
||||
|
||||
return err;
|
||||
}
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
@ -433,21 +138,3 @@ export const stripeWebhookHandler = async (req: Request): Promise<Response> => {
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
@ -0,0 +1,185 @@
|
||||
import type { SubscriptionClaim } from '@prisma/client';
|
||||
import { SubscriptionStatus } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { createOrganisation } from '@documenso/lib/server-only/organisation/create-organisation';
|
||||
import { type Stripe } from '@documenso/lib/server-only/stripe';
|
||||
import type { StripeOrganisationCreateMetadata } from '@documenso/lib/types/subscription';
|
||||
import { ZStripeOrganisationCreateMetadataSchema } from '@documenso/lib/types/subscription';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { createOrganisationClaimUpsertData, extractStripeClaim } from './on-subscription-updated';
|
||||
|
||||
export type OnSubscriptionCreatedOptions = {
|
||||
subscription: Stripe.Subscription;
|
||||
};
|
||||
|
||||
type StripeWebhookResponse = {
|
||||
success: boolean;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export const onSubscriptionCreated = async ({ subscription }: OnSubscriptionCreatedOptions) => {
|
||||
const customerId =
|
||||
typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id;
|
||||
|
||||
// Todo: logging
|
||||
if (subscription.items.data.length !== 1) {
|
||||
console.error('No support for multiple items');
|
||||
|
||||
throw Response.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'No support for multiple items',
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const organisationId = await handleOrganisationCreateOrGet({
|
||||
subscription,
|
||||
customerId,
|
||||
});
|
||||
|
||||
const subscriptionItem = subscription.items.data[0];
|
||||
const subscriptionClaim = await extractStripeClaim(subscriptionItem.price);
|
||||
|
||||
// Todo: logging
|
||||
if (!subscriptionClaim) {
|
||||
console.error(`Subscription claim on ${subscriptionItem.price.id} not found`);
|
||||
|
||||
throw Response.json(
|
||||
{
|
||||
success: false,
|
||||
message: `Subscription claim on ${subscriptionItem.price.id} not found`,
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
await handleSubscriptionCreate({
|
||||
subscription,
|
||||
customerId,
|
||||
organisationId,
|
||||
subscriptionClaim,
|
||||
});
|
||||
};
|
||||
|
||||
type HandleSubscriptionCreateOptions = {
|
||||
subscription: Stripe.Subscription;
|
||||
customerId: string;
|
||||
organisationId: string;
|
||||
subscriptionClaim: SubscriptionClaim;
|
||||
};
|
||||
|
||||
const handleSubscriptionCreate = async ({
|
||||
subscription,
|
||||
customerId,
|
||||
organisationId,
|
||||
subscriptionClaim,
|
||||
}: HandleSubscriptionCreateOptions) => {
|
||||
const status = match(subscription.status)
|
||||
.with('active', () => SubscriptionStatus.ACTIVE)
|
||||
.with('past_due', () => SubscriptionStatus.PAST_DUE)
|
||||
.otherwise(() => SubscriptionStatus.INACTIVE);
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.subscription.create({
|
||||
data: {
|
||||
organisationId,
|
||||
status: status,
|
||||
planId: subscription.id,
|
||||
priceId: subscription.items.data[0].price.id,
|
||||
periodEnd: new Date(subscription.current_period_end * 1000),
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
customerId,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.organisationClaim.create({
|
||||
data: {
|
||||
organisation: {
|
||||
connect: {
|
||||
id: organisationId,
|
||||
},
|
||||
},
|
||||
originalSubscriptionClaimId: subscriptionClaim.id,
|
||||
...createOrganisationClaimUpsertData(subscriptionClaim),
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
type HandleOrganisationCreateOrGetOptions = {
|
||||
subscription: Stripe.Subscription;
|
||||
customerId: string;
|
||||
};
|
||||
|
||||
const handleOrganisationCreateOrGet = async ({
|
||||
subscription,
|
||||
customerId,
|
||||
}: HandleOrganisationCreateOrGetOptions) => {
|
||||
let organisationCreateFlowData: StripeOrganisationCreateMetadata | null = null;
|
||||
|
||||
if (subscription.metadata?.organisationCreateData) {
|
||||
const parseResult = ZStripeOrganisationCreateMetadataSchema.safeParse(
|
||||
JSON.parse(subscription.metadata.organisationCreateData),
|
||||
);
|
||||
|
||||
if (!parseResult.success) {
|
||||
console.error('Invalid organisation create flow data');
|
||||
|
||||
throw Response.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Invalid organisation create flow data',
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
organisationCreateFlowData = parseResult.data;
|
||||
|
||||
const createdOrganisation = await createOrganisation({
|
||||
name: organisationCreateFlowData.organisationName,
|
||||
userId: organisationCreateFlowData.userId,
|
||||
});
|
||||
|
||||
return createdOrganisation.id;
|
||||
}
|
||||
|
||||
const organisation = await prisma.organisation.findFirst({
|
||||
where: {
|
||||
customerId,
|
||||
},
|
||||
include: {
|
||||
subscription: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!organisation) {
|
||||
throw Response.json(
|
||||
{
|
||||
success: false,
|
||||
message: `Organisation not found`,
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
// Todo: logging
|
||||
if (organisation.subscription) {
|
||||
console.error('Organisation already has a subscription');
|
||||
|
||||
// This should never happen
|
||||
throw Response.json(
|
||||
{
|
||||
success: false,
|
||||
message: `Organisation already has a subscription`,
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
return organisation.id;
|
||||
};
|
||||
@ -1,59 +1,178 @@
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import type { Prisma, SubscriptionClaim } from '@prisma/client';
|
||||
import { SubscriptionStatus } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import type { Stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { type Stripe, stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type OnSubscriptionUpdatedOptions = {
|
||||
userId?: number;
|
||||
teamId: number;
|
||||
subscription: Stripe.Subscription;
|
||||
previousAttributes: Partial<Stripe.Subscription> | null;
|
||||
};
|
||||
|
||||
type StripeWebhookResponse = {
|
||||
success: boolean;
|
||||
message: string;
|
||||
};
|
||||
|
||||
// Todo: orgs check if the subscription matches since it's possible to have more than one (via stripe)
|
||||
export const onSubscriptionUpdated = async ({
|
||||
userId,
|
||||
teamId,
|
||||
subscription,
|
||||
previousAttributes,
|
||||
}: OnSubscriptionUpdatedOptions) => {
|
||||
await prisma.subscription.upsert(
|
||||
mapStripeSubscriptionToPrismaUpsertAction(subscription, userId, teamId),
|
||||
);
|
||||
};
|
||||
const customerId =
|
||||
typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id;
|
||||
|
||||
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.');
|
||||
// Todo: logging
|
||||
if (subscription.items.data.length !== 1) {
|
||||
console.error('No support for multiple items');
|
||||
|
||||
throw Response.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'No support for multiple items',
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const organisation = await prisma.organisation.findFirst({
|
||||
where: {
|
||||
customerId,
|
||||
},
|
||||
include: {
|
||||
organisationClaim: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!organisation) {
|
||||
throw Response.json(
|
||||
{
|
||||
success: false,
|
||||
message: `Organisation not found`,
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const previousItem = previousAttributes?.items?.data[0];
|
||||
const updatedItem = subscription.items.data[0];
|
||||
|
||||
const previousSubscriptionClaimId = previousItem
|
||||
? await extractStripeClaimId(previousItem.price)
|
||||
: null;
|
||||
const updatedSubscriptionClaim = await extractStripeClaim(updatedItem.price);
|
||||
|
||||
if (!updatedSubscriptionClaim) {
|
||||
console.error(`Subscription claim on ${updatedItem.price.id} not found`);
|
||||
|
||||
throw Response.json(
|
||||
{
|
||||
success: false,
|
||||
message: `Subscription claim on ${updatedItem.price.id} not found`,
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const newClaimFound = previousSubscriptionClaimId !== updatedSubscriptionClaim.id;
|
||||
|
||||
const status = match(subscription.status)
|
||||
.with('active', () => SubscriptionStatus.ACTIVE)
|
||||
.with('past_due', () => SubscriptionStatus.PAST_DUE)
|
||||
.otherwise(() => SubscriptionStatus.INACTIVE);
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.subscription.update({
|
||||
where: {
|
||||
planId: subscription.id,
|
||||
},
|
||||
data: {
|
||||
organisationId: organisation.id,
|
||||
status: status,
|
||||
planId: subscription.id,
|
||||
priceId: subscription.items.data[0].price.id,
|
||||
periodEnd: new Date(subscription.current_period_end * 1000),
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
customerId,
|
||||
},
|
||||
});
|
||||
|
||||
// Override current organisation claim if new one is found.
|
||||
if (newClaimFound) {
|
||||
await tx.organisationClaim.update({
|
||||
where: {
|
||||
id: organisation.organisationClaim.id,
|
||||
},
|
||||
data: {
|
||||
originalSubscriptionClaimId: updatedSubscriptionClaim.id,
|
||||
...createOrganisationClaimUpsertData(updatedSubscriptionClaim),
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const createOrganisationClaimUpsertData = (subscriptionClaim: SubscriptionClaim) => {
|
||||
// Done like this to ensure type errors are thrown if items are added.
|
||||
const data: Omit<
|
||||
Prisma.SubscriptionClaimCreateInput,
|
||||
'id' | 'createdAt' | 'updatedAt' | 'locked' | 'name'
|
||||
> = {
|
||||
flags: {
|
||||
...subscriptionClaim.flags,
|
||||
},
|
||||
teamCount: subscriptionClaim.teamCount,
|
||||
memberCount: subscriptionClaim.memberCount,
|
||||
};
|
||||
|
||||
return {
|
||||
where: {
|
||||
planId: subscription.id,
|
||||
},
|
||||
create: {
|
||||
status: status,
|
||||
planId: subscription.id,
|
||||
priceId: subscription.items.data[0].price.id,
|
||||
periodEnd: new Date(subscription.current_period_end * 1000),
|
||||
userId: userId ?? null,
|
||||
teamId: teamId ?? null,
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
},
|
||||
update: {
|
||||
status: status,
|
||||
planId: subscription.id,
|
||||
priceId: subscription.items.data[0].price.id,
|
||||
periodEnd: new Date(subscription.current_period_end * 1000),
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
},
|
||||
...data,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks the price metadata for a claimId, if it is missing it will fetch
|
||||
* and check the product metadata for a claimId.
|
||||
*
|
||||
* The order of priority is:
|
||||
* 1. Price metadata
|
||||
* 2. Product metadata
|
||||
*
|
||||
* @returns The claimId or null if no claimId is found.
|
||||
*/
|
||||
export const extractStripeClaimId = async (priceId: Stripe.Price) => {
|
||||
if (priceId.metadata.claimId) {
|
||||
return priceId.metadata.claimId;
|
||||
}
|
||||
|
||||
const productId = typeof priceId.product === 'string' ? priceId.product : priceId.product.id;
|
||||
|
||||
const product = await stripe.products.retrieve(productId);
|
||||
|
||||
return product.metadata.claimId || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks the price metadata for a claimId, if it is missing it will fetch
|
||||
* and check the product metadata for a claimId.
|
||||
*
|
||||
*/
|
||||
export const extractStripeClaim = async (priceId: Stripe.Price) => {
|
||||
const claimId = await extractStripeClaimId(priceId);
|
||||
|
||||
if (!claimId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const subscriptionClaim = await prisma.subscriptionClaim.findFirst({
|
||||
where: { id: claimId },
|
||||
});
|
||||
|
||||
if (!subscriptionClaim) {
|
||||
console.error(`Subscription claim ${claimId} not found`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return subscriptionClaim;
|
||||
};
|
||||
|
||||
@ -1,57 +0,0 @@
|
||||
import type { Subscription } from '@prisma/client';
|
||||
|
||||
import { subscriptionsContainsActivePlan } from '@documenso/lib/utils/billing';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { getCommunityPlanPriceIds } from '../stripe/get-community-plan-prices';
|
||||
|
||||
export type IsCommunityPlanOptions = {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the user or team is on the community plan.
|
||||
*/
|
||||
export const isCommunityPlan = async ({
|
||||
userId,
|
||||
teamId,
|
||||
}: IsCommunityPlanOptions): Promise<boolean> => {
|
||||
let subscriptions: Subscription[] = [];
|
||||
|
||||
if (teamId) {
|
||||
subscriptions = await prisma.team
|
||||
.findFirstOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
},
|
||||
select: {
|
||||
owner: {
|
||||
include: {
|
||||
subscriptions: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
.then((team) => team.owner.subscriptions);
|
||||
} else {
|
||||
subscriptions = await prisma.user
|
||||
.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
subscriptions: true,
|
||||
},
|
||||
})
|
||||
.then((user) => user.subscriptions);
|
||||
}
|
||||
|
||||
if (subscriptions.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const communityPlanPriceIds = await getCommunityPlanPriceIds();
|
||||
|
||||
return subscriptionsContainsActivePlan(subscriptions, communityPlanPriceIds);
|
||||
};
|
||||
@ -1,65 +0,0 @@
|
||||
import type { Subscription } from '@prisma/client';
|
||||
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { subscriptionsContainsActivePlan } from '@documenso/lib/utils/billing';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { getEnterprisePlanPriceIds } from '../stripe/get-enterprise-plan-prices';
|
||||
|
||||
export type IsUserEnterpriseOptions = {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the user is enterprise, or has permission to use enterprise features on
|
||||
* behalf of their team.
|
||||
*
|
||||
* It is assumed that the provided user is part of the provided team.
|
||||
*/
|
||||
export const isUserEnterprise = async ({
|
||||
userId,
|
||||
teamId,
|
||||
}: IsUserEnterpriseOptions): Promise<boolean> => {
|
||||
let subscriptions: Subscription[] = [];
|
||||
|
||||
if (!IS_BILLING_ENABLED()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (teamId) {
|
||||
subscriptions = await prisma.team
|
||||
.findFirstOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
},
|
||||
select: {
|
||||
owner: {
|
||||
include: {
|
||||
subscriptions: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
.then((team) => team.owner.subscriptions);
|
||||
} else {
|
||||
subscriptions = await prisma.user
|
||||
.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
subscriptions: true,
|
||||
},
|
||||
})
|
||||
.then((user) => user.subscriptions);
|
||||
}
|
||||
|
||||
if (subscriptions.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const enterprisePlanPriceIds = await getEnterprisePlanPriceIds();
|
||||
|
||||
return subscriptionsContainsActivePlan(subscriptions, enterprisePlanPriceIds, true);
|
||||
};
|
||||
@ -1,62 +0,0 @@
|
||||
import type { Document, Subscription } from '@prisma/client';
|
||||
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { subscriptionsContainsActivePlan } from '@documenso/lib/utils/billing';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { getPlatformPlanPriceIds } from '../stripe/get-platform-plan-prices';
|
||||
|
||||
export type IsDocumentPlatformOptions = Pick<Document, 'id' | 'userId' | 'teamId'>;
|
||||
|
||||
/**
|
||||
* Whether the user is platform, or has permission to use platform features on
|
||||
* behalf of their team.
|
||||
*
|
||||
* It is assumed that the provided user is part of the provided team.
|
||||
*/
|
||||
export const isDocumentPlatform = async ({
|
||||
userId,
|
||||
teamId,
|
||||
}: IsDocumentPlatformOptions): Promise<boolean> => {
|
||||
let subscriptions: Subscription[] = [];
|
||||
|
||||
if (!IS_BILLING_ENABLED()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (teamId) {
|
||||
subscriptions = await prisma.team
|
||||
.findFirstOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
},
|
||||
select: {
|
||||
owner: {
|
||||
include: {
|
||||
subscriptions: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
.then((team) => team.owner.subscriptions);
|
||||
} else {
|
||||
subscriptions = await prisma.user
|
||||
.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
subscriptions: true,
|
||||
},
|
||||
})
|
||||
.then((user) => user.subscriptions);
|
||||
}
|
||||
|
||||
if (subscriptions.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const platformPlanPriceIds = await getPlatformPlanPriceIds();
|
||||
|
||||
return subscriptionsContainsActivePlan(subscriptions, platformPlanPriceIds);
|
||||
};
|
||||
@ -23,7 +23,6 @@ export type OrganisationInviteEmailProps = {
|
||||
baseUrl: string;
|
||||
senderName: string;
|
||||
organisationName: string;
|
||||
teamName?: string;
|
||||
token: string;
|
||||
};
|
||||
|
||||
@ -32,13 +31,12 @@ export const OrganisationInviteEmailTemplate = ({
|
||||
baseUrl = 'https://documenso.com',
|
||||
senderName = 'John Doe',
|
||||
organisationName = 'Organisation Name',
|
||||
teamName = 'Team Name',
|
||||
token = '',
|
||||
}: OrganisationInviteEmailProps) => {
|
||||
const { _ } = useLingui();
|
||||
const branding = useBranding();
|
||||
|
||||
const previewText = msg`Accept invitation to join a team on Documenso`;
|
||||
const previewText = msg`Accept invitation to join an organisation on Documenso`;
|
||||
|
||||
return (
|
||||
<Html>
|
||||
@ -72,15 +70,11 @@ export const OrganisationInviteEmailTemplate = ({
|
||||
</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base">
|
||||
{teamName ? (
|
||||
<Trans>You have been invited to join the following team</Trans>
|
||||
) : (
|
||||
<Trans>You have been invited to join the following organisation</Trans>
|
||||
)}
|
||||
<Trans>You have been invited to join the following organisation</Trans>
|
||||
</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">
|
||||
{teamName || organisationName}
|
||||
{organisationName}
|
||||
</div>
|
||||
|
||||
<Text className="my-1 text-center text-base">
|
||||
@ -92,13 +86,13 @@ export const OrganisationInviteEmailTemplate = ({
|
||||
<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}`}
|
||||
href={`${baseUrl}/organisation/invite/${token}`}
|
||||
>
|
||||
<Trans>Accept</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
className="ml-4 inline-flex items-center justify-center rounded-lg bg-gray-50 px-6 py-3 text-center text-sm font-medium text-slate-600 no-underline"
|
||||
href={`${baseUrl}/team/decline/${token}`}
|
||||
href={`${baseUrl}/organisation/decline/${token}`}
|
||||
>
|
||||
<Trans>Decline</Trans>
|
||||
</Button>
|
||||
|
||||
@ -2,34 +2,32 @@ import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
|
||||
import { formatTeamUrl } from '@documenso/lib/utils/teams';
|
||||
|
||||
import { Body, Container, Head, Hr, Html, Img, Preview, Section, Text } from '../components';
|
||||
import { useBranding } from '../providers/branding';
|
||||
import { TemplateFooter } from '../template-components/template-footer';
|
||||
import TemplateImage from '../template-components/template-image';
|
||||
|
||||
export type TeamJoinEmailProps = {
|
||||
export type OrganisationJoinEmailProps = {
|
||||
assetBaseUrl: string;
|
||||
baseUrl: string;
|
||||
memberName: string;
|
||||
memberEmail: string;
|
||||
teamName: string;
|
||||
teamUrl: string;
|
||||
organisationName: string;
|
||||
organisationUrl: string;
|
||||
};
|
||||
|
||||
export const TeamJoinEmailTemplate = ({
|
||||
export const OrganisationJoinEmailTemplate = ({
|
||||
assetBaseUrl = 'http://localhost:3002',
|
||||
baseUrl = 'https://documenso.com',
|
||||
memberName = 'John Doe',
|
||||
memberEmail = 'johndoe@documenso.com',
|
||||
teamName = 'Team Name',
|
||||
teamUrl = 'demo',
|
||||
}: TeamJoinEmailProps) => {
|
||||
organisationName = 'Organisation Name',
|
||||
organisationUrl = 'demo',
|
||||
}: OrganisationJoinEmailProps) => {
|
||||
const { _ } = useLingui();
|
||||
const branding = useBranding();
|
||||
|
||||
const previewText = msg`A team member has joined a team on Documenso`;
|
||||
const previewText = msg`A member has joined your organisation on Documenso`;
|
||||
|
||||
return (
|
||||
<Html>
|
||||
@ -59,17 +57,11 @@ export const TeamJoinEmailTemplate = ({
|
||||
|
||||
<Section className="p-2 text-slate-500">
|
||||
<Text className="text-center text-lg font-medium text-black">
|
||||
<Trans>
|
||||
{memberName || memberEmail} joined the team {teamName} on Documenso
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base">
|
||||
<Trans>{memberEmail} joined the following team</Trans>
|
||||
<Trans>A new member has joined your organisation {organisationName}</Trans>
|
||||
</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">
|
||||
{formatTeamUrl(teamUrl, baseUrl)}
|
||||
{memberName || memberEmail}
|
||||
</div>
|
||||
</Section>
|
||||
</Container>
|
||||
@ -85,4 +77,4 @@ export const TeamJoinEmailTemplate = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default TeamJoinEmailTemplate;
|
||||
export default OrganisationJoinEmailTemplate;
|
||||
@ -2,34 +2,32 @@ import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
|
||||
import { formatTeamUrl } from '@documenso/lib/utils/teams';
|
||||
|
||||
import { Body, Container, Head, Hr, Html, Img, Preview, Section, Text } from '../components';
|
||||
import { useBranding } from '../providers/branding';
|
||||
import { TemplateFooter } from '../template-components/template-footer';
|
||||
import TemplateImage from '../template-components/template-image';
|
||||
|
||||
export type TeamLeaveEmailProps = {
|
||||
export type OrganisationLeaveEmailProps = {
|
||||
assetBaseUrl: string;
|
||||
baseUrl: string;
|
||||
memberName: string;
|
||||
memberEmail: string;
|
||||
teamName: string;
|
||||
teamUrl: string;
|
||||
organisationName: string;
|
||||
organisationUrl: string;
|
||||
};
|
||||
|
||||
export const TeamLeaveEmailTemplate = ({
|
||||
export const OrganisationLeaveEmailTemplate = ({
|
||||
assetBaseUrl = 'http://localhost:3002',
|
||||
baseUrl = 'https://documenso.com',
|
||||
memberName = 'John Doe',
|
||||
memberEmail = 'johndoe@documenso.com',
|
||||
teamName = 'Team Name',
|
||||
teamUrl = 'demo',
|
||||
}: TeamLeaveEmailProps) => {
|
||||
organisationName = 'Organisation Name',
|
||||
organisationUrl = 'demo',
|
||||
}: OrganisationLeaveEmailProps) => {
|
||||
const { _ } = useLingui();
|
||||
const branding = useBranding();
|
||||
|
||||
const previewText = msg`A team member has left a team on Documenso`;
|
||||
const previewText = msg`A member has left your organisation on Documenso`;
|
||||
|
||||
return (
|
||||
<Html>
|
||||
@ -59,17 +57,11 @@ export const TeamLeaveEmailTemplate = ({
|
||||
|
||||
<Section className="p-2 text-slate-500">
|
||||
<Text className="text-center text-lg font-medium text-black">
|
||||
<Trans>
|
||||
{memberName || memberEmail} left the team {teamName} on Documenso
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base">
|
||||
<Trans>{memberEmail} left the following team</Trans>
|
||||
<Trans>A member has left your organisation {organisationName}</Trans>
|
||||
</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">
|
||||
{formatTeamUrl(teamUrl, baseUrl)}
|
||||
{memberName || memberEmail}
|
||||
</div>
|
||||
</Section>
|
||||
</Container>
|
||||
@ -85,4 +77,4 @@ export const TeamLeaveEmailTemplate = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default TeamLeaveEmailTemplate;
|
||||
export default OrganisationLeaveEmailTemplate;
|
||||
33
packages/lib/client-only/providers/organisation.tsx
Normal file
33
packages/lib/client-only/providers/organisation.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import type { OrganisationSession } from '@documenso/trpc/server/organisation-router/get-organisation-session.types';
|
||||
|
||||
type OrganisationProviderValue = OrganisationSession;
|
||||
|
||||
interface OrganisationProviderProps {
|
||||
children: React.ReactNode;
|
||||
organisation: OrganisationProviderValue | null;
|
||||
}
|
||||
|
||||
const OrganisationContext = createContext<OrganisationProviderValue | null>(null);
|
||||
|
||||
export const useCurrentOrganisation = () => {
|
||||
const context = useContext(OrganisationContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useCurrentOrganisation must be used within a OrganisationProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export const useOptionalCurrentOrganisation = () => {
|
||||
return useContext(OrganisationContext);
|
||||
};
|
||||
|
||||
export const OrganisationProvider = ({ children, organisation }: OrganisationProviderProps) => {
|
||||
return (
|
||||
<OrganisationContext.Provider value={organisation}>{children}</OrganisationContext.Provider>
|
||||
);
|
||||
};
|
||||
@ -1,12 +1,18 @@
|
||||
export enum STRIPE_CUSTOMER_TYPE {
|
||||
INDIVIDUAL = 'individual',
|
||||
ORGANISATION = 'organisation',
|
||||
}
|
||||
import { SubscriptionStatus } from '@prisma/client';
|
||||
|
||||
export enum STRIPE_PLAN_TYPE {
|
||||
REGULAR = 'regular',
|
||||
TEAM = 'team',
|
||||
COMMUNITY = 'community',
|
||||
FREE = 'free',
|
||||
INDIVIDUAL = 'individual',
|
||||
PRO = 'pro',
|
||||
EARLY_ADOPTER = 'earlyAdopter',
|
||||
PLATFORM = 'platform',
|
||||
ENTERPRISE = 'enterprise',
|
||||
}
|
||||
|
||||
export const FREE_TIER_DOCUMENT_QUOTA = 5;
|
||||
|
||||
export const SUBSCRIPTION_STATUS_MAP = {
|
||||
[SubscriptionStatus.ACTIVE]: 'Active',
|
||||
[SubscriptionStatus.INACTIVE]: 'Inactive',
|
||||
[SubscriptionStatus.PAST_DUE]: 'Past Due',
|
||||
};
|
||||
|
||||
@ -2,7 +2,6 @@ import { env } from '@documenso/lib/utils/env';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from './app';
|
||||
|
||||
const NEXT_PUBLIC_FEATURE_BILLING_ENABLED = () => env('NEXT_PUBLIC_FEATURE_BILLING_ENABLED');
|
||||
const NEXT_PUBLIC_POSTHOG_KEY = () => env('NEXT_PUBLIC_POSTHOG_KEY');
|
||||
|
||||
/**
|
||||
@ -10,26 +9,6 @@ const NEXT_PUBLIC_POSTHOG_KEY = () => env('NEXT_PUBLIC_POSTHOG_KEY');
|
||||
*/
|
||||
export const FEATURE_FLAG_GLOBAL_SESSION_RECORDING = 'global_session_recording';
|
||||
|
||||
/**
|
||||
* How frequent to poll for new feature flags in milliseconds.
|
||||
*/
|
||||
export const FEATURE_FLAG_POLL_INTERVAL = 30000;
|
||||
|
||||
/**
|
||||
* Feature flags that will be used when PostHog is disabled.
|
||||
*
|
||||
* Does not take any person or group properties into account.
|
||||
*/
|
||||
export const LOCAL_FEATURE_FLAGS: Record<string, boolean> = {
|
||||
app_allow_encrypted_documents: false,
|
||||
app_billing: NEXT_PUBLIC_FEATURE_BILLING_ENABLED() === 'true',
|
||||
app_document_page_view_history_sheet: false,
|
||||
app_passkey: true,
|
||||
app_public_profile: true,
|
||||
marketing_header_single_player_mode: false,
|
||||
marketing_profiles_announcement_bar: true,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Extract the PostHog configuration from the environment.
|
||||
*/
|
||||
@ -46,10 +25,3 @@ export function extractPostHogConfig(): { key: string; host: string } | null {
|
||||
host: postHogHost,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether feature flags are enabled for the current instance.
|
||||
*/
|
||||
export function isFeatureFlagEnabled(): boolean {
|
||||
return extractPostHogConfig() !== null;
|
||||
}
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
import { JobClient } from './client/client';
|
||||
import { SEND_CONFIRMATION_EMAIL_JOB_DEFINITION } from './definitions/emails/send-confirmation-email';
|
||||
import { SEND_DOCUMENT_CANCELLED_EMAILS_JOB_DEFINITION } from './definitions/emails/send-document-cancelled-emails';
|
||||
import { SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-organisation-member-joined-email';
|
||||
import { SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION } from './definitions/emails/send-organisation-member-left-email';
|
||||
import { SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION } from './definitions/emails/send-password-reset-success-email';
|
||||
import { SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-recipient-signed-email';
|
||||
import { SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION } from './definitions/emails/send-rejection-emails';
|
||||
import { SEND_SIGNING_EMAIL_JOB_DEFINITION } from './definitions/emails/send-signing-email';
|
||||
import { SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-deleted-email';
|
||||
import { SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-member-joined-email';
|
||||
import { SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION } from './definitions/emails/send-team-member-left-email';
|
||||
import { BULK_SEND_TEMPLATE_JOB_DEFINITION } from './definitions/internal/bulk-send-template';
|
||||
import { SEAL_DOCUMENT_JOB_DEFINITION } from './definitions/internal/seal-document';
|
||||
|
||||
@ -18,8 +18,8 @@ import { SEAL_DOCUMENT_JOB_DEFINITION } from './definitions/internal/seal-docume
|
||||
export const jobsClient = new JobClient([
|
||||
SEND_SIGNING_EMAIL_JOB_DEFINITION,
|
||||
SEND_CONFIRMATION_EMAIL_JOB_DEFINITION,
|
||||
SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION,
|
||||
SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION,
|
||||
SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION,
|
||||
SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION,
|
||||
SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION,
|
||||
SEAL_DOCUMENT_JOB_DEFINITION,
|
||||
SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION,
|
||||
|
||||
@ -1,80 +1,85 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { TeamMemberRole } from '@prisma/client';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import TeamJoinEmailTemplate from '@documenso/email/templates/team-join';
|
||||
import OrganisationJoinEmailTemplate from '@documenso/email/templates/organisation-join';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
|
||||
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
|
||||
import { getTeamSettings } from '../../../server-only/team/get-team-settings';
|
||||
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '../../../constants/organisations';
|
||||
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
|
||||
import { teamGlobalSettingsToBranding } from '../../../utils/team-global-settings-to-branding';
|
||||
import { organisationGlobalSettingsToBranding } from '../../../utils/team-global-settings-to-branding';
|
||||
import type { JobRunIO } from '../../client/_internal/job';
|
||||
import type { TSendTeamMemberJoinedEmailJobDefinition } from './send-team-member-joined-email';
|
||||
import type { TSendOrganisationMemberJoinedEmailJobDefinition } from './send-organisation-member-joined-email';
|
||||
|
||||
export const run = async ({
|
||||
payload,
|
||||
io,
|
||||
}: {
|
||||
payload: TSendTeamMemberJoinedEmailJobDefinition;
|
||||
payload: TSendOrganisationMemberJoinedEmailJobDefinition;
|
||||
io: JobRunIO;
|
||||
}) => {
|
||||
const team = await prisma.team.findFirstOrThrow({
|
||||
const organisation = await prisma.organisation.findFirstOrThrow({
|
||||
where: {
|
||||
id: payload.teamId,
|
||||
id: payload.organisationId,
|
||||
},
|
||||
include: {
|
||||
members: {
|
||||
where: {
|
||||
role: {
|
||||
in: [TeamMemberRole.ADMIN, TeamMemberRole.MANAGER],
|
||||
organisationGroupMembers: {
|
||||
some: {
|
||||
group: {
|
||||
organisationRole: {
|
||||
in: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
},
|
||||
organisationGlobalSettings: true,
|
||||
},
|
||||
});
|
||||
|
||||
const settings = await getTeamSettings({
|
||||
userId: payload.userId,
|
||||
teamId: payload.teamId,
|
||||
});
|
||||
|
||||
const invitedMember = await prisma.teamMember.findFirstOrThrow({
|
||||
const invitedMember = await prisma.organisationMember.findFirstOrThrow({
|
||||
where: {
|
||||
id: payload.memberId,
|
||||
teamId: payload.teamId,
|
||||
userId: payload.memberUserId,
|
||||
organisationId: payload.organisationId,
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const member of team.members) {
|
||||
for (const member of organisation.members) {
|
||||
if (member.id === invitedMember.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await io.runTask(
|
||||
`send-team-member-joined-email--${invitedMember.id}_${member.id}`,
|
||||
`send-organisation-member-joined-email--${invitedMember.id}_${member.id}`,
|
||||
async () => {
|
||||
const emailContent = createElement(TeamJoinEmailTemplate, {
|
||||
const emailContent = createElement(OrganisationJoinEmailTemplate, {
|
||||
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
|
||||
baseUrl: NEXT_PUBLIC_WEBAPP_URL(),
|
||||
memberName: invitedMember.user.name || '',
|
||||
memberEmail: invitedMember.user.email,
|
||||
teamName: team.name,
|
||||
teamUrl: team.url,
|
||||
organisationName: organisation.name,
|
||||
organisationUrl: organisation.url,
|
||||
});
|
||||
|
||||
const branding = teamGlobalSettingsToBranding(settings, team.id);
|
||||
const lang = settings.documentLanguage;
|
||||
const branding = organisationGlobalSettingsToBranding(
|
||||
organisation.organisationGlobalSettings,
|
||||
organisation.id,
|
||||
);
|
||||
|
||||
const lang = organisation.organisationGlobalSettings.documentLanguage;
|
||||
|
||||
// !: Replace with the actual language of the recipient later
|
||||
const [html, text] = await Promise.all([
|
||||
@ -97,7 +102,7 @@ export const run = async ({
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: i18n._(msg`A new member has joined your team`),
|
||||
subject: i18n._(msg`A new member has joined your organisation`),
|
||||
html,
|
||||
text,
|
||||
});
|
||||
@ -0,0 +1,33 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { JobDefinition } from '../../client/_internal/job';
|
||||
|
||||
const SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION_ID =
|
||||
'send.organisation-member-joined.email';
|
||||
|
||||
const SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
|
||||
organisationId: z.string(),
|
||||
memberUserId: z.number(),
|
||||
});
|
||||
|
||||
export type TSendOrganisationMemberJoinedEmailJobDefinition = z.infer<
|
||||
typeof SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION_SCHEMA
|
||||
>;
|
||||
|
||||
export const SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION = {
|
||||
id: SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION_ID,
|
||||
name: 'Send Organisation Member Joined Email',
|
||||
version: '1.0.0',
|
||||
trigger: {
|
||||
name: SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION_ID,
|
||||
schema: SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION_SCHEMA,
|
||||
},
|
||||
handler: async ({ payload, io }) => {
|
||||
const handler = await import('./send-organisation-member-joined-email.handler');
|
||||
|
||||
await handler.run({ payload, io });
|
||||
},
|
||||
} as const satisfies JobDefinition<
|
||||
typeof SEND_ORGANISATION_MEMBER_JOINED_EMAIL_JOB_DEFINITION_ID,
|
||||
TSendOrganisationMemberJoinedEmailJobDefinition
|
||||
>;
|
||||
@ -0,0 +1,107 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import OrganisationLeaveEmailTemplate from '@documenso/email/templates/organisation-leave';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
|
||||
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
|
||||
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '../../../constants/organisations';
|
||||
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
|
||||
import { organisationGlobalSettingsToBranding } from '../../../utils/team-global-settings-to-branding';
|
||||
import type { JobRunIO } from '../../client/_internal/job';
|
||||
import type { TSendOrganisationMemberLeftEmailJobDefinition } from './send-organisation-member-left-email';
|
||||
|
||||
export const run = async ({
|
||||
payload,
|
||||
io,
|
||||
}: {
|
||||
payload: TSendOrganisationMemberLeftEmailJobDefinition;
|
||||
io: JobRunIO;
|
||||
}) => {
|
||||
const organisation = await prisma.organisation.findFirstOrThrow({
|
||||
where: {
|
||||
id: payload.organisationId,
|
||||
},
|
||||
include: {
|
||||
members: {
|
||||
where: {
|
||||
organisationGroupMembers: {
|
||||
some: {
|
||||
group: {
|
||||
organisationRole: {
|
||||
in: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
},
|
||||
organisationGlobalSettings: true,
|
||||
},
|
||||
});
|
||||
|
||||
const oldMember = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: payload.memberUserId,
|
||||
},
|
||||
});
|
||||
|
||||
for (const member of organisation.members) {
|
||||
if (member.userId === oldMember.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await io.runTask(
|
||||
`send-organisation-member-left-email--${oldMember.id}_${member.id}`,
|
||||
async () => {
|
||||
const emailContent = createElement(OrganisationLeaveEmailTemplate, {
|
||||
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
|
||||
baseUrl: NEXT_PUBLIC_WEBAPP_URL(),
|
||||
memberName: oldMember.name || '',
|
||||
memberEmail: oldMember.email,
|
||||
organisationName: organisation.name,
|
||||
organisationUrl: organisation.url,
|
||||
});
|
||||
|
||||
const branding = organisationGlobalSettingsToBranding(
|
||||
organisation.organisationGlobalSettings,
|
||||
organisation.id,
|
||||
);
|
||||
|
||||
const lang = organisation.organisationGlobalSettings.documentLanguage;
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(emailContent, {
|
||||
lang,
|
||||
branding,
|
||||
}),
|
||||
renderEmailWithI18N(emailContent, {
|
||||
lang,
|
||||
branding,
|
||||
plainText: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const i18n = await getI18nInstance(lang);
|
||||
|
||||
await mailer.sendMail({
|
||||
to: member.user.email,
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: i18n._(msg`A member has left your organisation`),
|
||||
html,
|
||||
text,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,32 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { JobDefinition } from '../../client/_internal/job';
|
||||
|
||||
const SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION_ID = 'send.organisation-member-left.email';
|
||||
|
||||
const SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
|
||||
organisationId: z.string(),
|
||||
memberUserId: z.number(),
|
||||
});
|
||||
|
||||
export type TSendOrganisationMemberLeftEmailJobDefinition = z.infer<
|
||||
typeof SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION_SCHEMA
|
||||
>;
|
||||
|
||||
export const SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION = {
|
||||
id: SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION_ID,
|
||||
name: 'Send Organisation Member Left Email',
|
||||
version: '1.0.0',
|
||||
trigger: {
|
||||
name: SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION_ID,
|
||||
schema: SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION_SCHEMA,
|
||||
},
|
||||
handler: async ({ payload, io }) => {
|
||||
const handler = await import('./send-organisation-member-left-email.handler');
|
||||
|
||||
await handler.run({ payload, io });
|
||||
},
|
||||
} as const satisfies JobDefinition<
|
||||
typeof SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION_ID,
|
||||
TSendOrganisationMemberLeftEmailJobDefinition
|
||||
>;
|
||||
@ -1,4 +1,3 @@
|
||||
import { DocumentVisibility } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { JobDefinition } from '../../client/_internal/job';
|
||||
@ -9,23 +8,24 @@ const SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
|
||||
team: z.object({
|
||||
name: z.string(),
|
||||
url: z.string(),
|
||||
teamGlobalSettings: z
|
||||
.object({
|
||||
documentVisibility: z.nativeEnum(DocumentVisibility),
|
||||
documentLanguage: z.string(),
|
||||
includeSenderDetails: z.boolean(),
|
||||
includeSigningCertificate: z.boolean(),
|
||||
brandingEnabled: z.boolean(),
|
||||
brandingLogo: z.string(),
|
||||
brandingUrl: z.string(),
|
||||
brandingCompanyDetails: z.string(),
|
||||
brandingHidePoweredBy: z.boolean(),
|
||||
teamId: z.number(),
|
||||
typedSignatureEnabled: z.boolean(),
|
||||
uploadSignatureEnabled: z.boolean(),
|
||||
drawSignatureEnabled: z.boolean(),
|
||||
})
|
||||
.nullish(),
|
||||
// This is never passed along for some reason so commenting it out.
|
||||
// teamGlobalSettings: z
|
||||
// .object({
|
||||
// documentVisibility: z.nativeEnum(DocumentVisibility),
|
||||
// documentLanguage: z.string(),
|
||||
// includeSenderDetails: z.boolean(),
|
||||
// includeSigningCertificate: z.boolean(),
|
||||
// brandingEnabled: z.boolean(),
|
||||
// brandingLogo: z.string(),
|
||||
// brandingUrl: z.string(),
|
||||
// brandingCompanyDetails: z.string(),
|
||||
// brandingHidePoweredBy: z.boolean(),
|
||||
// teamId: z.number(),
|
||||
// typedSignatureEnabled: z.boolean(),
|
||||
// uploadSignatureEnabled: z.boolean(),
|
||||
// drawSignatureEnabled: z.boolean(),
|
||||
// })
|
||||
// .nullish(),
|
||||
}),
|
||||
members: z.array(
|
||||
z.object({
|
||||
|
||||
@ -1,32 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { JobDefinition } from '../../client/_internal/job';
|
||||
|
||||
const SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION_ID = 'send.team-member-joined.email';
|
||||
|
||||
const SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
|
||||
teamId: z.number(),
|
||||
memberId: z.number(),
|
||||
});
|
||||
|
||||
export type TSendTeamMemberJoinedEmailJobDefinition = z.infer<
|
||||
typeof SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION_SCHEMA
|
||||
>;
|
||||
|
||||
export const SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION = {
|
||||
id: SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION_ID,
|
||||
name: 'Send Team Member Joined Email',
|
||||
version: '1.0.0',
|
||||
trigger: {
|
||||
name: SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION_ID,
|
||||
schema: SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION_SCHEMA,
|
||||
},
|
||||
handler: async ({ payload, io }) => {
|
||||
const handler = await import('./send-team-member-joined-email.handler');
|
||||
|
||||
await handler.run({ payload, io });
|
||||
},
|
||||
} as const satisfies JobDefinition<
|
||||
typeof SEND_TEAM_MEMBER_JOINED_EMAIL_JOB_DEFINITION_ID,
|
||||
TSendTeamMemberJoinedEmailJobDefinition
|
||||
>;
|
||||
@ -1,94 +0,0 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { TeamMemberRole } from '@prisma/client';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import TeamJoinEmailTemplate from '@documenso/email/templates/team-join';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
|
||||
import { FROM_ADDRESS, FROM_NAME } from '../../../constants/email';
|
||||
import { getTeamSettings } from '../../../server-only/team/get-team-settings';
|
||||
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
|
||||
import { teamGlobalSettingsToBranding } from '../../../utils/team-global-settings-to-branding';
|
||||
import type { JobRunIO } from '../../client/_internal/job';
|
||||
import type { TSendTeamMemberLeftEmailJobDefinition } from './send-team-member-left-email';
|
||||
|
||||
export const run = async ({
|
||||
payload,
|
||||
io,
|
||||
}: {
|
||||
payload: TSendTeamMemberLeftEmailJobDefinition;
|
||||
io: JobRunIO;
|
||||
}) => {
|
||||
const team = await prisma.team.findFirstOrThrow({
|
||||
where: {
|
||||
id: payload.teamId,
|
||||
},
|
||||
include: {
|
||||
members: {
|
||||
where: {
|
||||
role: {
|
||||
in: [TeamMemberRole.ADMIN, TeamMemberRole.MANAGER],
|
||||
},
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const settings = await getTeamSettings({
|
||||
teamId: payload.teamId,
|
||||
});
|
||||
|
||||
const oldMember = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: payload.memberUserId,
|
||||
},
|
||||
});
|
||||
|
||||
for (const member of team.members) {
|
||||
await io.runTask(`send-team-member-left-email--${oldMember.id}_${member.id}`, async () => {
|
||||
const emailContent = createElement(TeamJoinEmailTemplate, {
|
||||
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
|
||||
baseUrl: NEXT_PUBLIC_WEBAPP_URL(),
|
||||
memberName: oldMember.name || '',
|
||||
memberEmail: oldMember.email,
|
||||
teamName: team.name,
|
||||
teamUrl: team.url,
|
||||
});
|
||||
|
||||
const branding = teamGlobalSettingsToBranding(settings, team.id);
|
||||
const lang = settings.documentLanguage;
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(emailContent, {
|
||||
lang,
|
||||
branding,
|
||||
}),
|
||||
renderEmailWithI18N(emailContent, {
|
||||
lang,
|
||||
branding,
|
||||
plainText: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const i18n = await getI18nInstance(lang);
|
||||
|
||||
await mailer.sendMail({
|
||||
to: member.user.email,
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: i18n._(msg`A team member has left ${team.name}`),
|
||||
html,
|
||||
text,
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -1,32 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { JobDefinition } from '../../client/_internal/job';
|
||||
|
||||
const SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION_ID = 'send.team-member-left.email';
|
||||
|
||||
const SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
|
||||
teamId: z.number(),
|
||||
memberUserId: z.number(),
|
||||
});
|
||||
|
||||
export type TSendTeamMemberLeftEmailJobDefinition = z.infer<
|
||||
typeof SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION_SCHEMA
|
||||
>;
|
||||
|
||||
export const SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION = {
|
||||
id: SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION_ID,
|
||||
name: 'Send Team Member Left Email',
|
||||
version: '1.0.0',
|
||||
trigger: {
|
||||
name: SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION_ID,
|
||||
schema: SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION_SCHEMA,
|
||||
},
|
||||
handler: async ({ payload, io }) => {
|
||||
const handler = await import('./send-team-member-left-email.handler');
|
||||
|
||||
await handler.run({ payload, io });
|
||||
},
|
||||
} as const satisfies JobDefinition<
|
||||
typeof SEND_TEAM_MEMBER_LEFT_EMAIL_JOB_DEFINITION_ID,
|
||||
TSendTeamMemberLeftEmailJobDefinition
|
||||
>;
|
||||
@ -1,13 +0,0 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export const findSubscriptions = async () => {
|
||||
return prisma.subscription.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
periodEnd: true,
|
||||
userId: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -29,37 +29,26 @@ export async function getSigningVolume({
|
||||
|
||||
let findQuery = kyselyPrisma.$kysely
|
||||
.selectFrom('Subscription as s')
|
||||
.leftJoin('User as u', 's.userId', 'u.id')
|
||||
.leftJoin('Team as t', 's.teamId', 't.id')
|
||||
.leftJoin('Document as ud', (join) =>
|
||||
.innerJoin('Organisation as o', 's.organisationId', 'o.id')
|
||||
.leftJoin('Team as t', 'o.id', 't.organisationId')
|
||||
.leftJoin('Document as d', (join) =>
|
||||
join
|
||||
.onRef('u.id', '=', 'ud.userId')
|
||||
.on('ud.status', '=', sql.lit(DocumentStatus.COMPLETED))
|
||||
.on('ud.deletedAt', 'is', null)
|
||||
.on('ud.teamId', 'is', null),
|
||||
)
|
||||
.leftJoin('Document as td', (join) =>
|
||||
join
|
||||
.onRef('t.id', '=', 'td.teamId')
|
||||
.on('td.status', '=', sql.lit(DocumentStatus.COMPLETED))
|
||||
.on('td.deletedAt', 'is', null),
|
||||
.onRef('t.id', '=', 'd.teamId')
|
||||
.on('d.status', '=', sql.lit(DocumentStatus.COMPLETED))
|
||||
.on('d.deletedAt', 'is', null),
|
||||
)
|
||||
.where(sql`s.status = ${SubscriptionStatus.ACTIVE}::"SubscriptionStatus"`)
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb('u.name', 'ilike', `%${search}%`),
|
||||
eb('u.email', 'ilike', `%${search}%`),
|
||||
eb('t.name', 'ilike', `%${search}%`),
|
||||
]),
|
||||
eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
|
||||
)
|
||||
.select([
|
||||
's.id as id',
|
||||
's.createdAt as createdAt',
|
||||
's.planId as planId',
|
||||
sql<string>`COALESCE(u.name, t.name, u.email, 'Unknown')`.as('name'),
|
||||
sql<number>`COUNT(DISTINCT ud.id) + COUNT(DISTINCT td.id)`.as('signingVolume'),
|
||||
sql<string>`COALESCE(o.name, 'Unknown')`.as('name'),
|
||||
sql<number>`COUNT(DISTINCT d.id)`.as('signingVolume'),
|
||||
])
|
||||
.groupBy(['s.id', 'u.name', 't.name', 'u.email']);
|
||||
.groupBy(['s.id', 'o.name']);
|
||||
|
||||
switch (sortBy) {
|
||||
case 'name':
|
||||
@ -79,15 +68,11 @@ export async function getSigningVolume({
|
||||
|
||||
const countQuery = kyselyPrisma.$kysely
|
||||
.selectFrom('Subscription as s')
|
||||
.leftJoin('User as u', 's.userId', 'u.id')
|
||||
.leftJoin('Team as t', 's.teamId', 't.id')
|
||||
.innerJoin('Organisation as o', 's.organisationId', 'o.id')
|
||||
.leftJoin('Team as t', 'o.id', 't.organisationId')
|
||||
.where(sql`s.status = ${SubscriptionStatus.ACTIVE}::"SubscriptionStatus"`)
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb('u.name', 'ilike', `%${search}%`),
|
||||
eb('u.email', 'ilike', `%${search}%`),
|
||||
eb('t.name', 'ilike', `%${search}%`),
|
||||
]),
|
||||
eb.or([eb('o.name', 'ilike', `%${search}%`), eb('t.name', 'ilike', `%${search}%`)]),
|
||||
)
|
||||
.select(({ fn }) => [fn.countAll().as('count')]);
|
||||
|
||||
|
||||
@ -7,13 +7,11 @@ export const getUsersCount = async () => {
|
||||
return await prisma.user.count();
|
||||
};
|
||||
|
||||
export const getUsersWithSubscriptionsCount = async () => {
|
||||
return await prisma.user.count({
|
||||
export const getOrganisationsWithSubscriptionsCount = async () => {
|
||||
return await prisma.organisation.count({
|
||||
where: {
|
||||
subscriptions: {
|
||||
some: {
|
||||
status: SubscriptionStatus.ACTIVE,
|
||||
},
|
||||
subscription: {
|
||||
status: SubscriptionStatus.ACTIVE,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -7,7 +7,6 @@ import {
|
||||
WebhookTriggerEvents,
|
||||
} from '@prisma/client';
|
||||
|
||||
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { normalizePdf as makeNormalizedPdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
@ -27,6 +26,7 @@ import { getFileServerSide } from '../../universal/upload/get-file.server';
|
||||
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
|
||||
import { createDocumentAuthOptions, createRecipientAuthOptions } from '../../utils/document-auth';
|
||||
import { determineDocumentVisibility } from '../../utils/document-visibility';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
import { getMemberRoles } from '../team/get-member-roles';
|
||||
import { getTeamSettings } from '../team/get-team-settings';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
@ -60,6 +60,23 @@ export const createDocumentV2 = async ({
|
||||
}: CreateDocumentOptions) => {
|
||||
const { title, formValues } = data;
|
||||
|
||||
const team = await prisma.team.findFirst({
|
||||
where: buildTeamWhereQuery(userId, teamId),
|
||||
include: {
|
||||
organisation: {
|
||||
select: {
|
||||
organisationClaim: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Team not found',
|
||||
});
|
||||
}
|
||||
|
||||
const settings = await getTeamSettings({
|
||||
userId,
|
||||
teamId,
|
||||
@ -96,17 +113,13 @@ export const createDocumentV2 = async ({
|
||||
const recipientsHaveActionAuth = data.recipients?.some((recipient) => recipient.actionAuth);
|
||||
|
||||
// Check if user has permission to set the global action auth.
|
||||
if (authOptions.globalActionAuth || recipientsHaveActionAuth) {
|
||||
const isDocumentEnterprise = await isUserEnterprise({
|
||||
userId,
|
||||
teamId,
|
||||
if (
|
||||
(authOptions.globalActionAuth || recipientsHaveActionAuth) &&
|
||||
!team.organisation.organisationClaim.flags.cfr21
|
||||
) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to set the action auth',
|
||||
});
|
||||
|
||||
if (!isDocumentEnterprise) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to set the action auth',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const { teamRole } = await getMemberRoles({
|
||||
|
||||
@ -2,7 +2,6 @@ import { DocumentVisibility } from '@prisma/client';
|
||||
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import type { CreateDocumentAuditLogDataResponse } from '@documenso/lib/utils/document-audit-logs';
|
||||
@ -43,6 +42,17 @@ export const updateDocument = async ({
|
||||
|
||||
const document = await prisma.document.findFirst({
|
||||
where: documentWhereInput,
|
||||
include: {
|
||||
team: {
|
||||
select: {
|
||||
organisation: {
|
||||
select: {
|
||||
organisationClaim: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
@ -108,17 +118,10 @@ export const updateDocument = async ({
|
||||
data?.globalActionAuth === undefined ? documentGlobalActionAuth : data.globalActionAuth;
|
||||
|
||||
// Check if user has permission to set the global action auth.
|
||||
if (newGlobalActionAuth) {
|
||||
const isDocumentEnterprise = await isUserEnterprise({
|
||||
userId,
|
||||
teamId,
|
||||
if (newGlobalActionAuth && !document.team.organisation.organisationClaim.flags.cfr21) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to set the action auth',
|
||||
});
|
||||
|
||||
if (!isDocumentEnterprise) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to set the action auth',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const isTitleSame = data.title === undefined || data.title === document.title;
|
||||
|
||||
@ -3,6 +3,7 @@ import { OrganisationGroupType, OrganisationMemberInviteStatus } from '@prisma/c
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { jobs } from '../../jobs/client';
|
||||
|
||||
export type AcceptOrganisationInvitationOptions = {
|
||||
token: string;
|
||||
@ -21,7 +22,8 @@ export const acceptOrganisationInvitation = async ({
|
||||
include: {
|
||||
organisation: {
|
||||
include: {
|
||||
subscriptions: true,
|
||||
subscription: true,
|
||||
organisationClaim: true,
|
||||
groups: {
|
||||
include: {
|
||||
teamGroups: true,
|
||||
@ -46,19 +48,10 @@ export const acceptOrganisationInvitation = async ({
|
||||
},
|
||||
});
|
||||
|
||||
// If no user exists for the invitation, accept the invitation and create the organisation
|
||||
// user when the user signs up.
|
||||
if (!user) {
|
||||
await prisma.organisationMemberInvite.update({
|
||||
where: {
|
||||
id: organisationMemberInvite.id,
|
||||
},
|
||||
data: {
|
||||
status: OrganisationMemberInviteStatus.ACCEPTED,
|
||||
},
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'User must exist to accept an organisation invitation',
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const { organisation } = organisationMemberInvite;
|
||||
@ -98,28 +91,13 @@ export const acceptOrganisationInvitation = async ({
|
||||
},
|
||||
});
|
||||
|
||||
// Todo: Orgs
|
||||
// if (IS_BILLING_ENABLED() && team.subscription) {
|
||||
// const numberOfSeats = await tx.teamMember.count({
|
||||
// where: {
|
||||
// teamId: organisationMemberInvite.teamId,
|
||||
// },
|
||||
// });
|
||||
|
||||
// await updateSubscriptionItemQuantity({
|
||||
// priceId: team.subscription.priceId,
|
||||
// subscriptionId: team.subscription.planId,
|
||||
// quantity: numberOfSeats,
|
||||
// });
|
||||
// }
|
||||
|
||||
// await jobs.triggerJob({
|
||||
// name: 'send.team-member-joined.email',
|
||||
// payload: {
|
||||
// teamId: teamMember.teamId,
|
||||
// memberId: teamMember.id,
|
||||
// },
|
||||
// });
|
||||
await jobs.triggerJob({
|
||||
name: 'send.organisation-member-joined.email',
|
||||
payload: {
|
||||
organisationId: organisation.id,
|
||||
memberUserId: user.id,
|
||||
},
|
||||
});
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
|
||||
@ -5,6 +5,7 @@ import type { Organisation, OrganisationGlobalSettings, Prisma } from '@prisma/c
|
||||
import { OrganisationMemberInviteStatus } from '@prisma/client';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
import { syncMemberCountWithStripeSeatPlan } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { OrganisationInviteEmailTemplate } from '@documenso/email/templates/organisation-invite';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
@ -16,12 +17,11 @@ import { prisma } from '@documenso/prisma';
|
||||
import type { TCreateOrganisationMemberInvitesRequestSchema } from '@documenso/trpc/server/organisation-router/create-organisation-member-invites.types';
|
||||
|
||||
import { getI18nInstance } from '../../client-only/providers/i18n-server';
|
||||
import {
|
||||
buildOrganisationWhereQuery,
|
||||
getHighestOrganisationRoleInGroup,
|
||||
} from '../../utils/organisations';
|
||||
import { validateIfSubscriptionIsRequired } from '../../utils/billing';
|
||||
import { buildOrganisationWhereQuery } from '../../utils/organisations';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { organisationGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
|
||||
import { getMemberOrganisationRole } from '../team/get-member-roles';
|
||||
|
||||
export type CreateOrganisationMemberInvitesOptions = {
|
||||
userId: number;
|
||||
@ -56,8 +56,14 @@ export const createOrganisationMemberInvites = async ({
|
||||
},
|
||||
},
|
||||
},
|
||||
invites: true,
|
||||
invites: {
|
||||
where: {
|
||||
status: OrganisationMemberInviteStatus.PENDING,
|
||||
},
|
||||
},
|
||||
organisationGlobalSettings: true,
|
||||
organisationClaim: true,
|
||||
subscription: true,
|
||||
},
|
||||
});
|
||||
|
||||
@ -65,38 +71,20 @@ export const createOrganisationMemberInvites = async ({
|
||||
throw new AppError(AppErrorCode.NOT_FOUND);
|
||||
}
|
||||
|
||||
const currentOrganisationMember = await prisma.organisationMember.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
organisationId,
|
||||
},
|
||||
include: {
|
||||
organisationGroupMembers: {
|
||||
include: {
|
||||
group: true,
|
||||
},
|
||||
},
|
||||
const { organisationClaim } = organisation;
|
||||
|
||||
const subscription = validateIfSubscriptionIsRequired(organisation.subscription);
|
||||
|
||||
const currentOrganisationMemberRole = await getMemberOrganisationRole({
|
||||
organisationId: organisation.id,
|
||||
reference: {
|
||||
type: 'User',
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!currentOrganisationMember) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
const currentOrganisationMemberRole = getHighestOrganisationRoleInGroup(
|
||||
currentOrganisationMember.organisationGroupMembers.map((member) => member.group),
|
||||
);
|
||||
|
||||
const organisationMemberEmails = organisation.members.map((member) => member.user.email);
|
||||
const organisationMemberInviteEmails = organisation.invites
|
||||
.filter((invite) => invite.status === OrganisationMemberInviteStatus.PENDING)
|
||||
.map((invite) => invite.email);
|
||||
|
||||
if (!currentOrganisationMember) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'User not part of organisation.',
|
||||
});
|
||||
}
|
||||
const organisationMemberInviteEmails = organisation.invites.map((invite) => invite.email);
|
||||
|
||||
const usersToInvite = invitations.filter((invitation) => {
|
||||
// Filter out users that are already members of the organisation.
|
||||
@ -123,7 +111,6 @@ export const createOrganisationMemberInvites = async ({
|
||||
});
|
||||
}
|
||||
|
||||
// Todo: (orgs)
|
||||
const organisationMemberInvites: Prisma.OrganisationMemberInviteCreateManyInput[] =
|
||||
usersToInvite.map(({ email, organisationRole }) => ({
|
||||
email,
|
||||
@ -132,9 +119,21 @@ export const createOrganisationMemberInvites = async ({
|
||||
token: nanoid(32),
|
||||
}));
|
||||
|
||||
console.log({
|
||||
organisationMemberInvites,
|
||||
});
|
||||
const numberOfCurrentMembers = organisation.members.length;
|
||||
const numberOfCurrentInvites = organisation.invites.length;
|
||||
const numberOfNewInvites = organisationMemberInvites.length;
|
||||
|
||||
const totalMemberCountWithInvites =
|
||||
numberOfCurrentMembers + numberOfCurrentInvites + numberOfNewInvites;
|
||||
|
||||
// Handle billing for seat based plans.
|
||||
if (subscription) {
|
||||
await syncMemberCountWithStripeSeatPlan(
|
||||
subscription,
|
||||
organisationClaim,
|
||||
totalMemberCountWithInvites,
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.organisationMemberInvite.createMany({
|
||||
data: organisationMemberInvites,
|
||||
|
||||
@ -5,32 +5,45 @@ import { prisma } from '@documenso/prisma';
|
||||
import { ORGANISATION_INTERNAL_GROUPS } from '../../constants/organisations';
|
||||
import { AppErrorCode } from '../../errors/app-error';
|
||||
import { AppError } from '../../errors/app-error';
|
||||
import { alphaid } from '../../universal/id';
|
||||
import { alphaid, generatePrefixedId } from '../../universal/id';
|
||||
import { generateDefaultOrganisationSettings } from '../../utils/organisations';
|
||||
import { generateDefaultOrganisationClaims } from '../../utils/organisations-claims';
|
||||
import { createTeam } from '../team/create-team';
|
||||
|
||||
type CreateOrganisationOptions = {
|
||||
userId: number;
|
||||
name: string;
|
||||
url: string;
|
||||
url?: string;
|
||||
customerId?: string;
|
||||
};
|
||||
|
||||
export const createOrganisation = async ({ name, url, userId }: CreateOrganisationOptions) => {
|
||||
export const createOrganisation = async ({
|
||||
name,
|
||||
url,
|
||||
userId,
|
||||
customerId,
|
||||
}: CreateOrganisationOptions) => {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const organisationSetting = await tx.organisationGlobalSettings.create({
|
||||
data: generateDefaultOrganisationSettings(),
|
||||
});
|
||||
|
||||
const organisationClaim = await tx.organisationClaim.create({
|
||||
data: generateDefaultOrganisationClaims(),
|
||||
});
|
||||
|
||||
const organisation = await tx.organisation
|
||||
.create({
|
||||
data: {
|
||||
name,
|
||||
url, // Todo: orgs constraint this
|
||||
url: url || generatePrefixedId('org'),
|
||||
ownerUserId: userId,
|
||||
organisationGlobalSettingsId: organisationSetting.id,
|
||||
organisationClaimId: organisationClaim.id,
|
||||
groups: {
|
||||
create: ORGANISATION_INTERNAL_GROUPS,
|
||||
},
|
||||
customerId,
|
||||
},
|
||||
include: {
|
||||
groups: true,
|
||||
@ -86,7 +99,7 @@ export const createPersonalOrganisation = async ({
|
||||
const organisation = await createOrganisation({
|
||||
name: 'Personal Organisation',
|
||||
userId,
|
||||
url: orgUrl || `org_${alphaid(8)}`,
|
||||
url: orgUrl,
|
||||
}).catch((err) => {
|
||||
console.error(err);
|
||||
|
||||
@ -94,7 +107,7 @@ export const createPersonalOrganisation = async ({
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Todo: (orgs) Add logging.
|
||||
// Todo: (LOGS)
|
||||
});
|
||||
|
||||
if (organisation) {
|
||||
@ -106,7 +119,8 @@ export const createPersonalOrganisation = async ({
|
||||
inheritMembers: true,
|
||||
}).catch((err) => {
|
||||
console.error(err);
|
||||
// Todo: (orgs) Add logging.
|
||||
|
||||
// Todo: (LOGS)
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,39 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
|
||||
export const getOrganisationClaim = async ({ organisationId }: { organisationId: string }) => {
|
||||
const organisationClaim = await prisma.organisationClaim.findFirst({
|
||||
where: {
|
||||
organisation: {
|
||||
id: organisationId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!organisationClaim) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND);
|
||||
}
|
||||
|
||||
return organisationClaim;
|
||||
};
|
||||
|
||||
export const getOrganisationClaimByTeamId = async ({ teamId }: { teamId: number }) => {
|
||||
const organisationClaim = await prisma.organisationClaim.findFirst({
|
||||
where: {
|
||||
organisation: {
|
||||
teams: {
|
||||
some: {
|
||||
id: teamId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!organisationClaim) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND);
|
||||
}
|
||||
|
||||
return organisationClaim;
|
||||
};
|
||||
@ -1,13 +1,14 @@
|
||||
import { TeamMemberRole } from '@prisma/client';
|
||||
import type { Duration } from 'luxon';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams';
|
||||
// temporary choice for testing only
|
||||
import * as timeConstants from '../../constants/time';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { alphaid } from '../../universal/id';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
import { hashString } from '../auth/hash';
|
||||
|
||||
type TimeConstants = typeof timeConstants & {
|
||||
@ -33,20 +34,14 @@ export const createApiToken = async ({
|
||||
|
||||
const timeConstantsRecords: TimeConstants = timeConstants;
|
||||
|
||||
if (teamId) {
|
||||
const member = await prisma.teamMember.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
teamId,
|
||||
role: TeamMemberRole.ADMIN,
|
||||
},
|
||||
});
|
||||
const team = await prisma.team.findFirst({
|
||||
where: buildTeamWhereQuery(teamId, userId, TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM']),
|
||||
});
|
||||
|
||||
if (!member) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to create a token for this team',
|
||||
});
|
||||
}
|
||||
if (!team) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to create a token for this team',
|
||||
});
|
||||
}
|
||||
|
||||
const storedToken = await prisma.apiToken.create({
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import { TeamMemberRole } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
|
||||
export type DeleteTokenByIdOptions = {
|
||||
id: number;
|
||||
userId: number;
|
||||
@ -9,24 +11,20 @@ export type DeleteTokenByIdOptions = {
|
||||
};
|
||||
|
||||
export const deleteTokenById = async ({ id, userId, teamId }: DeleteTokenByIdOptions) => {
|
||||
if (teamId) {
|
||||
const member = await prisma.teamMember.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
teamId,
|
||||
role: TeamMemberRole.ADMIN,
|
||||
},
|
||||
});
|
||||
const team = await prisma.team.findFirst({
|
||||
where: buildTeamWhereQuery(teamId, userId, TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM']),
|
||||
});
|
||||
|
||||
if (!member) {
|
||||
throw new Error('You do not have permission to delete this token');
|
||||
}
|
||||
if (!team) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to delete this token',
|
||||
});
|
||||
}
|
||||
|
||||
return await prisma.apiToken.delete({
|
||||
where: {
|
||||
id,
|
||||
teamId: teamId ?? null,
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { RecipientRole } from '@prisma/client';
|
||||
import { SendStatus, SigningStatus } from '@prisma/client';
|
||||
|
||||
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth';
|
||||
import { type TRecipientActionAuthTypes } from '@documenso/lib/types/document-auth';
|
||||
@ -46,6 +45,15 @@ export const createDocumentRecipients = async ({
|
||||
where: documentWhereInput,
|
||||
include: {
|
||||
recipients: true,
|
||||
team: {
|
||||
select: {
|
||||
organisation: {
|
||||
select: {
|
||||
organisationClaim: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -64,17 +72,10 @@ export const createDocumentRecipients = async ({
|
||||
const recipientsHaveActionAuth = recipientsToCreate.some((recipient) => recipient.actionAuth);
|
||||
|
||||
// Check if user has permission to set the global action auth.
|
||||
if (recipientsHaveActionAuth) {
|
||||
const isEnterprise = await isUserEnterprise({
|
||||
userId,
|
||||
teamId,
|
||||
if (recipientsHaveActionAuth && !document.team.organisation.organisationClaim.flags.cfr21) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to set the action auth',
|
||||
});
|
||||
|
||||
if (!isEnterprise) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to set the action auth',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedRecipients = recipientsToCreate.map((recipient) => ({
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { RecipientRole } from '@prisma/client';
|
||||
import { SendStatus, SigningStatus } from '@prisma/client';
|
||||
|
||||
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||
import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth';
|
||||
import { type TRecipientActionAuthTypes } from '@documenso/lib/types/document-auth';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
@ -38,6 +37,15 @@ export const createTemplateRecipients = async ({
|
||||
},
|
||||
include: {
|
||||
recipients: true,
|
||||
team: {
|
||||
select: {
|
||||
organisation: {
|
||||
select: {
|
||||
organisationClaim: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -50,17 +58,10 @@ export const createTemplateRecipients = async ({
|
||||
const recipientsHaveActionAuth = recipientsToCreate.some((recipient) => recipient.actionAuth);
|
||||
|
||||
// Check if user has permission to set the global action auth.
|
||||
if (recipientsHaveActionAuth) {
|
||||
const isEnterprise = await isUserEnterprise({
|
||||
userId,
|
||||
teamId,
|
||||
if (recipientsHaveActionAuth && !template.team.organisation.organisationClaim.flags.cfr21) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to set the action auth',
|
||||
});
|
||||
|
||||
if (!isEnterprise) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to set the action auth',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedRecipients = recipientsToCreate.map((recipient) => ({
|
||||
|
||||
@ -5,7 +5,6 @@ import type { Recipient } from '@prisma/client';
|
||||
import { RecipientRole } from '@prisma/client';
|
||||
import { SendStatus, SigningStatus } from '@prisma/client';
|
||||
|
||||
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import RecipientRemovedFromDocumentTemplate from '@documenso/email/templates/recipient-removed-from-document';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
@ -60,6 +59,15 @@ export const setDocumentRecipients = async ({
|
||||
include: {
|
||||
fields: true,
|
||||
documentMeta: true,
|
||||
team: {
|
||||
select: {
|
||||
organisation: {
|
||||
select: {
|
||||
organisationClaim: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -90,17 +98,10 @@ export const setDocumentRecipients = async ({
|
||||
const recipientsHaveActionAuth = recipients.some((recipient) => recipient.actionAuth);
|
||||
|
||||
// Check if user has permission to set the global action auth.
|
||||
if (recipientsHaveActionAuth) {
|
||||
const isDocumentEnterprise = await isUserEnterprise({
|
||||
userId,
|
||||
teamId,
|
||||
if (recipientsHaveActionAuth && !document.team.organisation.organisationClaim.flags.cfr21) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to set the action auth',
|
||||
});
|
||||
|
||||
if (!isDocumentEnterprise) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to set the action auth',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedRecipients = recipients.map((recipient) => ({
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import type { Recipient } from '@prisma/client';
|
||||
import { RecipientRole } from '@prisma/client';
|
||||
|
||||
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||
import {
|
||||
DIRECT_TEMPLATE_RECIPIENT_EMAIL,
|
||||
DIRECT_TEMPLATE_RECIPIENT_NAME,
|
||||
@ -44,6 +43,15 @@ export const setTemplateRecipients = async ({
|
||||
},
|
||||
include: {
|
||||
directLink: true,
|
||||
team: {
|
||||
select: {
|
||||
organisation: {
|
||||
select: {
|
||||
organisationClaim: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -54,17 +62,10 @@ export const setTemplateRecipients = async ({
|
||||
const recipientsHaveActionAuth = recipients.some((recipient) => recipient.actionAuth);
|
||||
|
||||
// Check if user has permission to set the global action auth.
|
||||
if (recipientsHaveActionAuth) {
|
||||
const isDocumentEnterprise = await isUserEnterprise({
|
||||
userId,
|
||||
teamId,
|
||||
if (recipientsHaveActionAuth && !template.team.organisation.organisationClaim.flags.cfr21) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to set the action auth',
|
||||
});
|
||||
|
||||
if (!isDocumentEnterprise) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to set the action auth',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedRecipients = recipients.map((recipient) => {
|
||||
|
||||
@ -2,7 +2,6 @@ import type { Recipient } from '@prisma/client';
|
||||
import { RecipientRole } from '@prisma/client';
|
||||
import { SendStatus, SigningStatus } from '@prisma/client';
|
||||
|
||||
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth';
|
||||
import {
|
||||
@ -47,6 +46,15 @@ export const updateDocumentRecipients = async ({
|
||||
include: {
|
||||
fields: true,
|
||||
recipients: true,
|
||||
team: {
|
||||
select: {
|
||||
organisation: {
|
||||
select: {
|
||||
organisationClaim: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -65,17 +73,10 @@ export const updateDocumentRecipients = async ({
|
||||
const recipientsHaveActionAuth = recipients.some((recipient) => recipient.actionAuth);
|
||||
|
||||
// Check if user has permission to set the global action auth.
|
||||
if (recipientsHaveActionAuth) {
|
||||
const isEnterprise = await isUserEnterprise({
|
||||
userId,
|
||||
teamId,
|
||||
if (recipientsHaveActionAuth && !document.team.organisation.organisationClaim.flags.cfr21) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to set the action auth',
|
||||
});
|
||||
|
||||
if (!isEnterprise) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to set the action auth',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const recipientsToUpdate = recipients.map((recipient) => {
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import type { RecipientRole, Team } from '@prisma/client';
|
||||
import type { RecipientRole } from '@prisma/client';
|
||||
|
||||
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
@ -12,6 +11,7 @@ import {
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData, diffRecipientChanges } from '../../utils/document-audit-logs';
|
||||
import { createRecipientAuthOptions } from '../../utils/document-auth';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
|
||||
export type UpdateRecipientOptions = {
|
||||
documentId: number;
|
||||
@ -44,57 +44,43 @@ export const updateRecipient = async ({
|
||||
document: {
|
||||
id: documentId,
|
||||
userId,
|
||||
team: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
team: buildTeamWhereQuery(teamId, userId), // Todo: orgs i know i messed the orders of some of these up somewhere
|
||||
},
|
||||
},
|
||||
include: {
|
||||
document: {
|
||||
include: {
|
||||
team: {
|
||||
include: {
|
||||
organisation: {
|
||||
select: {
|
||||
organisationClaim: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
document: true,
|
||||
},
|
||||
});
|
||||
|
||||
let team: Team | null = null;
|
||||
|
||||
if (teamId) {
|
||||
team = await prisma.team.findFirst({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!recipient) {
|
||||
// Todo: orgs check if this is supposed to only be documents
|
||||
if (!recipient || !recipient.document) {
|
||||
throw new Error('Recipient not found');
|
||||
}
|
||||
|
||||
if (actionAuth) {
|
||||
const isDocumentEnterprise = await isUserEnterprise({
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
const team = recipient.document.team;
|
||||
|
||||
if (!isDocumentEnterprise) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to set the action auth',
|
||||
});
|
||||
}
|
||||
if (actionAuth && !recipient.document.team.organisation.organisationClaim.flags.cfr21) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to set the action auth',
|
||||
});
|
||||
}
|
||||
|
||||
const recipientAuthOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { RecipientRole } from '@prisma/client';
|
||||
import { SendStatus, SigningStatus } from '@prisma/client';
|
||||
|
||||
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||
import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth';
|
||||
import {
|
||||
type TRecipientActionAuthTypes,
|
||||
@ -41,6 +40,15 @@ export const updateTemplateRecipients = async ({
|
||||
},
|
||||
include: {
|
||||
recipients: true,
|
||||
team: {
|
||||
select: {
|
||||
organisation: {
|
||||
select: {
|
||||
organisationClaim: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -53,17 +61,10 @@ export const updateTemplateRecipients = async ({
|
||||
const recipientsHaveActionAuth = recipients.some((recipient) => recipient.actionAuth);
|
||||
|
||||
// Check if user has permission to set the global action auth.
|
||||
if (recipientsHaveActionAuth) {
|
||||
const isEnterprise = await isUserEnterprise({
|
||||
userId,
|
||||
teamId,
|
||||
if (recipientsHaveActionAuth && !template.team.organisation.organisationClaim.flags.cfr21) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to set the action auth',
|
||||
});
|
||||
|
||||
if (!isEnterprise) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to set the action auth',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const recipientsToUpdate = recipients.map((recipient) => {
|
||||
|
||||
@ -1,13 +0,0 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type GetSubscriptionsByUserIdOptions = {
|
||||
userId: number;
|
||||
};
|
||||
|
||||
export const getSubscriptionsByUserId = async ({ userId }: GetSubscriptionsByUserIdOptions) => {
|
||||
return await prisma.subscription.findMany({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -1,54 +0,0 @@
|
||||
import { getCheckoutSession } from '@documenso/ee/server-only/stripe/get-checkout-session';
|
||||
import { getTeamPrices } from '@documenso/ee/server-only/stripe/get-team-prices';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type CreateTeamPendingCheckoutSession = {
|
||||
userId: number;
|
||||
pendingTeamId: number;
|
||||
interval: 'monthly' | 'yearly';
|
||||
};
|
||||
|
||||
export const createTeamPendingCheckoutSession = async ({
|
||||
userId,
|
||||
pendingTeamId,
|
||||
interval,
|
||||
}: CreateTeamPendingCheckoutSession) => {
|
||||
const teamPendingCreation = await prisma.teamPending.findFirstOrThrow({
|
||||
where: {
|
||||
id: pendingTeamId,
|
||||
ownerUserId: userId,
|
||||
},
|
||||
include: {
|
||||
owner: true,
|
||||
},
|
||||
});
|
||||
|
||||
const prices = await getTeamPrices();
|
||||
const priceId = prices[interval].priceId;
|
||||
|
||||
try {
|
||||
const stripeCheckoutSession = await getCheckoutSession({
|
||||
customerId: teamPendingCreation.customerId,
|
||||
priceId,
|
||||
returnUrl: `${NEXT_PUBLIC_WEBAPP_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, {
|
||||
message: 'Something went wrong.',
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -1,19 +1,7 @@
|
||||
import {
|
||||
OrganisationGroupType,
|
||||
OrganisationMemberRole,
|
||||
Prisma,
|
||||
TeamMemberRole,
|
||||
} from '@prisma/client';
|
||||
import type Stripe from 'stripe';
|
||||
import { OrganisationGroupType, OrganisationMemberRole, TeamMemberRole } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { createOrganisationCustomer } from '@documenso/ee/server-only/stripe/create-team-customer';
|
||||
import { getTeamRelatedPrices } from '@documenso/ee/server-only/stripe/get-team-related-prices';
|
||||
import { mapStripeSubscriptionToPrismaUpsertAction } from '@documenso/ee/server-only/stripe/webhook/on-subscription-updated';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { subscriptionsContainsActivePlan } from '@documenso/lib/utils/billing';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import {
|
||||
@ -23,7 +11,6 @@ import {
|
||||
import { TEAM_INTERNAL_GROUPS } from '../../constants/teams';
|
||||
import { buildOrganisationWhereQuery } from '../../utils/organisations';
|
||||
import { generateDefaultTeamSettings } from '../../utils/teams';
|
||||
import { stripe } from '../stripe';
|
||||
|
||||
export type CreateTeamOptions = {
|
||||
/**
|
||||
@ -41,7 +28,7 @@ export type CreateTeamOptions = {
|
||||
*
|
||||
* Used as the URL path, example: https://documenso.com/t/{teamUrl}/settings
|
||||
*/
|
||||
teamUrl: string;
|
||||
teamUrl: string; // Todo: orgs make unique
|
||||
|
||||
/**
|
||||
* ID of the organisation the team belongs to.
|
||||
@ -62,28 +49,13 @@ export type CreateTeamOptions = {
|
||||
}[];
|
||||
};
|
||||
|
||||
export const ZCreateTeamResponseSchema = z.union([
|
||||
z.object({
|
||||
paymentRequired: z.literal(false),
|
||||
}),
|
||||
z.object({
|
||||
paymentRequired: z.literal(true),
|
||||
pendingTeamId: z.number(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export type TCreateTeamResponse = z.infer<typeof ZCreateTeamResponseSchema>;
|
||||
|
||||
/**
|
||||
* Create a team or pending team depending on the user's subscription or application's billing settings.
|
||||
*/
|
||||
export const createTeam = async ({
|
||||
userId,
|
||||
teamName,
|
||||
teamUrl,
|
||||
organisationId,
|
||||
inheritMembers,
|
||||
}: CreateTeamOptions): Promise<TCreateTeamResponse> => {
|
||||
}: CreateTeamOptions) => {
|
||||
const organisation = await prisma.organisation.findFirst({
|
||||
where: buildOrganisationWhereQuery(
|
||||
organisationId,
|
||||
@ -91,8 +63,9 @@ export const createTeam = async ({
|
||||
ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
|
||||
),
|
||||
include: {
|
||||
groups: true, // Todo: (orgs)
|
||||
subscriptions: true,
|
||||
groups: true,
|
||||
subscription: true,
|
||||
organisationClaim: true,
|
||||
owner: {
|
||||
select: {
|
||||
id: true,
|
||||
@ -109,6 +82,21 @@ export const createTeam = async ({
|
||||
});
|
||||
}
|
||||
|
||||
// Validate they have enough team slots. 0 means they can create unlimited teams.
|
||||
if (organisation.organisationClaim.teamCount !== 0) {
|
||||
const teamCount = await prisma.team.count({
|
||||
where: {
|
||||
organisationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (teamCount >= organisation.organisationClaim.teamCount) {
|
||||
throw new AppError(AppErrorCode.LIMIT_EXCEEDED, {
|
||||
message: 'You have reached the maximum number of teams for your plan.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Inherit internal organisation groups to the team.
|
||||
// Organisation Admins/Mangers get assigned as team admins, members get assigned as team members.
|
||||
const internalOrganisationGroups = organisation.groups
|
||||
@ -141,254 +129,46 @@ export const createTeam = async ({
|
||||
.exhaustive(),
|
||||
);
|
||||
|
||||
console.log({
|
||||
internalOrganisationGroups,
|
||||
});
|
||||
|
||||
if (Date.now() > 0) {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const teamSettings = await tx.teamGlobalSettings.create({
|
||||
data: generateDefaultTeamSettings(),
|
||||
});
|
||||
|
||||
const team = await tx.team.create({
|
||||
data: {
|
||||
name: teamName,
|
||||
url: teamUrl,
|
||||
organisationId,
|
||||
teamGlobalSettingsId: teamSettings.id,
|
||||
teamGroups: {
|
||||
createMany: {
|
||||
// Attach the internal organisation groups to the team.
|
||||
data: internalOrganisationGroups,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
teamGroups: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Create the internal team groups.
|
||||
await Promise.all(
|
||||
TEAM_INTERNAL_GROUPS.map(async (teamGroup) =>
|
||||
tx.organisationGroup.create({
|
||||
data: {
|
||||
type: teamGroup.type,
|
||||
organisationRole: LOWEST_ORGANISATION_ROLE,
|
||||
organisationId,
|
||||
teamGroups: {
|
||||
create: {
|
||||
teamId: team.id,
|
||||
teamRole: teamGroup.teamRole,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
paymentRequired: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (Date.now() > 0) {
|
||||
throw new Error('Todo: Orgs');
|
||||
}
|
||||
|
||||
let isPaymentRequired = IS_BILLING_ENABLED();
|
||||
let customerId: string | null = null;
|
||||
|
||||
if (IS_BILLING_ENABLED()) {
|
||||
const teamRelatedPriceIds = await getTeamRelatedPrices().then((prices) =>
|
||||
prices.map((price) => price.id),
|
||||
);
|
||||
|
||||
isPaymentRequired = !subscriptionsContainsActivePlan(
|
||||
organisation.subscriptions,
|
||||
teamRelatedPriceIds, // Todo: (orgs)
|
||||
);
|
||||
|
||||
customerId = await createOrganisationCustomer({
|
||||
name: organisation.owner.name ?? teamName,
|
||||
email: organisation.owner.email,
|
||||
}).then((customer) => customer.id);
|
||||
|
||||
await prisma.organisation.update({
|
||||
where: {
|
||||
id: organisationId,
|
||||
},
|
||||
data: {
|
||||
customerId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Create the team directly if no payment is required.
|
||||
if (!isPaymentRequired) {
|
||||
await prisma.team.create({
|
||||
data: {
|
||||
name: teamName,
|
||||
url: teamUrl,
|
||||
organisationId,
|
||||
members: {
|
||||
create: [
|
||||
{
|
||||
userId,
|
||||
role: TeamMemberRole.ADMIN, // Todo: (orgs)
|
||||
},
|
||||
],
|
||||
},
|
||||
teamGlobalSettings: {
|
||||
create: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
paymentRequired: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Create a pending team if payment is required.
|
||||
const pendingTeam = await prisma.$transaction(async (tx) => {
|
||||
const existingTeamWithUrl = await tx.team.findUnique({
|
||||
where: {
|
||||
url: teamUrl,
|
||||
},
|
||||
});
|
||||
|
||||
const existingUserProfileWithUrl = await tx.user.findUnique({
|
||||
where: {
|
||||
url: teamUrl,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingUserProfileWithUrl) {
|
||||
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
|
||||
message: 'URL already taken.',
|
||||
});
|
||||
}
|
||||
|
||||
if (existingTeamWithUrl) {
|
||||
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
|
||||
message: 'Team URL already exists.',
|
||||
});
|
||||
}
|
||||
|
||||
if (!customerId) {
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||
message: 'Missing customer ID for pending teams.',
|
||||
});
|
||||
}
|
||||
|
||||
return await tx.teamPending.create({
|
||||
data: {
|
||||
name: teamName,
|
||||
url: teamUrl,
|
||||
ownerUserId: user.id,
|
||||
customerId,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
paymentRequired: true,
|
||||
pendingTeamId: pendingTeam.id,
|
||||
};
|
||||
} 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, {
|
||||
message: 'Team URL already exists.',
|
||||
});
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
export type CreateTeamFromPendingTeamOptions = {
|
||||
pendingTeamId: number;
|
||||
subscription: Stripe.Subscription;
|
||||
};
|
||||
|
||||
export const createTeamFromPendingTeam = async ({
|
||||
pendingTeamId,
|
||||
subscription,
|
||||
}: CreateTeamFromPendingTeamOptions) => {
|
||||
const createdTeam = await prisma.$transaction(async (tx) => {
|
||||
const pendingTeam = await tx.teamPending.findUniqueOrThrow({
|
||||
where: {
|
||||
id: pendingTeamId,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.teamPending.delete({
|
||||
where: {
|
||||
id: pendingTeamId,
|
||||
},
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const teamSettings = await tx.teamGlobalSettings.create({
|
||||
data: generateDefaultTeamSettings(),
|
||||
});
|
||||
|
||||
const team = await tx.team.create({
|
||||
data: {
|
||||
name: pendingTeam.name,
|
||||
url: pendingTeam.url,
|
||||
ownerUserId: pendingTeam.ownerUserId,
|
||||
customerId: pendingTeam.customerId,
|
||||
members: {
|
||||
create: [
|
||||
{
|
||||
userId: pendingTeam.ownerUserId,
|
||||
role: TeamMemberRole.ADMIN,
|
||||
},
|
||||
],
|
||||
name: teamName,
|
||||
url: teamUrl,
|
||||
organisationId,
|
||||
teamGlobalSettingsId: teamSettings.id,
|
||||
teamGroups: {
|
||||
createMany: {
|
||||
// Attach the internal organisation groups to the team.
|
||||
data: internalOrganisationGroups,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await tx.teamGlobalSettings.upsert({
|
||||
where: {
|
||||
teamId: team.id,
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
teamId: team.id,
|
||||
include: {
|
||||
teamGroups: true,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.subscription.upsert(
|
||||
mapStripeSubscriptionToPrismaUpsertAction(subscription, undefined, team.id),
|
||||
// Create the internal team groups.
|
||||
await Promise.all(
|
||||
TEAM_INTERNAL_GROUPS.map(async (teamGroup) =>
|
||||
tx.organisationGroup.create({
|
||||
data: {
|
||||
type: teamGroup.type,
|
||||
organisationRole: LOWEST_ORGANISATION_ROLE,
|
||||
organisationId,
|
||||
teamGroups: {
|
||||
create: {
|
||||
teamId: team.id,
|
||||
teamRole: teamGroup.teamRole,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return team;
|
||||
});
|
||||
|
||||
// Attach the team ID to the subscription metadata for sanity reasons.
|
||||
await stripe.subscriptions
|
||||
.update(subscription.id, {
|
||||
metadata: {
|
||||
teamId: createdTeam.id.toString(),
|
||||
},
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
// Non-critical error, but we want to log it so we can rectify it.
|
||||
// Todo: Teams - Alert us.
|
||||
});
|
||||
|
||||
return createdTeam;
|
||||
};
|
||||
|
||||
@ -1,15 +0,0 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -1,8 +1,8 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import type { OrganisationGlobalSettings } from '@prisma/client';
|
||||
import { OrganisationGroupType, type Team } from '@prisma/client';
|
||||
import { uniqueBy } from 'remeda';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { TeamDeleteEmailTemplate } from '@documenso/email/templates/team-delete';
|
||||
@ -13,8 +13,8 @@ import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { getI18nInstance } from '../../client-only/providers/i18n-server';
|
||||
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams';
|
||||
import { jobs } from '../../jobs/client';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { teamGlobalSettingsToBranding } from '../../utils/team-global-settings-to-branding';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
import { getTeamSettings } from './get-team-settings';
|
||||
|
||||
@ -28,6 +28,11 @@ export const deleteTeam = async ({ userId, teamId }: DeleteTeamOptions) => {
|
||||
const team = await prisma.team.findFirst({
|
||||
where: buildTeamWhereQuery(teamId, userId, TEAM_MEMBER_ROLE_PERMISSIONS_MAP['DELETE_TEAM']),
|
||||
include: {
|
||||
organisation: {
|
||||
select: {
|
||||
organisationGlobalSettings: true,
|
||||
},
|
||||
},
|
||||
teamGroups: {
|
||||
include: {
|
||||
organisationGroup: {
|
||||
@ -65,21 +70,19 @@ export const deleteTeam = async ({ userId, teamId }: DeleteTeamOptions) => {
|
||||
teamId,
|
||||
});
|
||||
|
||||
const membersToNotify = uniqueBy(
|
||||
team.teamGroups.flatMap((group) =>
|
||||
group.organisationGroup.organisationGroupMembers.map((member) => ({
|
||||
id: member.organisationMember.user.id,
|
||||
name: member.organisationMember.user.name || '',
|
||||
email: member.organisationMember.user.email,
|
||||
})),
|
||||
),
|
||||
(member) => member.id,
|
||||
);
|
||||
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
// Todo: orgs handle any subs?
|
||||
// if (team.subscription) {
|
||||
// await stripe.subscriptions
|
||||
// .cancel(team.subscription.planId, {
|
||||
// prorate: false,
|
||||
// invoice_now: true,
|
||||
// })
|
||||
// .catch((err) => {
|
||||
// console.error(err);
|
||||
// throw AppError.parseError(err);
|
||||
// });
|
||||
// }
|
||||
|
||||
await tx.team.delete({
|
||||
where: {
|
||||
id: teamId,
|
||||
@ -96,25 +99,20 @@ export const deleteTeam = async ({ userId, teamId }: DeleteTeamOptions) => {
|
||||
},
|
||||
});
|
||||
|
||||
// const members = team.teamGroups.flatMap((group) =>
|
||||
// group.organisationGroup.organisationMembers.map((member) => ({
|
||||
// id: member.user.id,
|
||||
// name: member.user.name || '',
|
||||
// email: member.user.email,
|
||||
// })),
|
||||
// );
|
||||
|
||||
// await jobs.triggerJob({
|
||||
// name: 'send.team-deleted.email',
|
||||
// payload: {
|
||||
// team: {
|
||||
// name: team.name,
|
||||
// url: team.url,
|
||||
// teamGlobalSettings: team.teamGlobalSettings, // Todo: orgs
|
||||
// },
|
||||
// members,
|
||||
// },
|
||||
// });
|
||||
await jobs.triggerJob({
|
||||
name: 'send.team-deleted.email',
|
||||
payload: {
|
||||
team: {
|
||||
name: team.name,
|
||||
url: team.url,
|
||||
// teamGlobalSettings: {
|
||||
// ...settings,
|
||||
// teamId: team.id,
|
||||
// },
|
||||
},
|
||||
members: membersToNotify,
|
||||
},
|
||||
});
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
@ -122,14 +120,14 @@ export const deleteTeam = async ({ userId, teamId }: DeleteTeamOptions) => {
|
||||
|
||||
type SendTeamDeleteEmailOptions = {
|
||||
email: string;
|
||||
team: Pick<Team, 'id' | 'url' | 'name'>;
|
||||
settings: Omit<OrganisationGlobalSettings, 'id'>;
|
||||
team: Pick<Team, 'url' | 'name'>;
|
||||
// settings: Omit<OrganisationGlobalSettings, 'id'>;
|
||||
};
|
||||
|
||||
export const sendTeamDeleteEmail = async ({
|
||||
email,
|
||||
team,
|
||||
settings,
|
||||
// settings,
|
||||
}: SendTeamDeleteEmailOptions) => {
|
||||
const template = createElement(TeamDeleteEmailTemplate, {
|
||||
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
|
||||
@ -137,9 +135,12 @@ export const sendTeamDeleteEmail = async ({
|
||||
teamUrl: team.url,
|
||||
});
|
||||
|
||||
const branding = teamGlobalSettingsToBranding(settings, team.id);
|
||||
// This is never actually passed on so commenting it out.
|
||||
// const branding = teamGlobalSettingsToBranding(settings, team.id);
|
||||
// const lang = settings.documentLanguage;
|
||||
|
||||
const lang = settings.documentLanguage;
|
||||
const branding = undefined;
|
||||
const lang = undefined;
|
||||
|
||||
const [html, text] = await Promise.all([
|
||||
renderEmailWithI18N(template, { lang, branding }),
|
||||
|
||||
@ -1,69 +0,0 @@
|
||||
import type { Team } from '@prisma/client';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { TeamPendingSchema } from '@documenso/prisma/generated/zod/modelSchema/TeamPendingSchema';
|
||||
|
||||
import { type FindResultResponse, ZFindResultResponse } from '../../types/search-params';
|
||||
|
||||
export interface FindTeamsPendingOptions {
|
||||
userId: number;
|
||||
query?: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
orderBy?: {
|
||||
column: keyof Team;
|
||||
direction: 'asc' | 'desc';
|
||||
};
|
||||
}
|
||||
|
||||
export const ZFindTeamsPendingResponseSchema = ZFindResultResponse.extend({
|
||||
data: TeamPendingSchema.array(),
|
||||
});
|
||||
|
||||
export type TFindTeamsPendingResponse = z.infer<typeof ZFindTeamsPendingResponseSchema>;
|
||||
|
||||
export const findTeamsPending = async ({
|
||||
userId,
|
||||
query,
|
||||
page = 1,
|
||||
perPage = 10,
|
||||
orderBy,
|
||||
}: FindTeamsPendingOptions): Promise<TFindTeamsPendingResponse> => {
|
||||
const orderByColumn = orderBy?.column ?? 'name';
|
||||
const orderByDirection = orderBy?.direction ?? 'desc';
|
||||
|
||||
const whereClause: Prisma.TeamPendingWhereInput = {
|
||||
ownerUserId: userId,
|
||||
};
|
||||
|
||||
if (query && query.length > 0) {
|
||||
whereClause.name = {
|
||||
contains: query,
|
||||
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),
|
||||
} satisfies FindResultResponse<typeof data>;
|
||||
};
|
||||
@ -19,7 +19,7 @@ export type UpdateTeamOptions = {
|
||||
export const updateTeam = async ({ userId, teamId, data }: UpdateTeamOptions): Promise<void> => {
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const foundPendingTeamWithUrl = await tx.teamPending.findFirst({
|
||||
const foundTeamWithUrl = await tx.team.findFirst({
|
||||
where: {
|
||||
url: data.url,
|
||||
},
|
||||
@ -31,21 +31,19 @@ export const updateTeam = async ({ userId, teamId, data }: UpdateTeamOptions): P
|
||||
},
|
||||
});
|
||||
|
||||
if (foundPendingTeamWithUrl || foundOrganisationWithUrl) {
|
||||
if (foundTeamWithUrl || foundOrganisationWithUrl) {
|
||||
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
|
||||
message: 'Team URL already exists.',
|
||||
});
|
||||
}
|
||||
|
||||
const team = await tx.team.update({
|
||||
return await tx.team.update({
|
||||
where: buildTeamWhereQuery(teamId, userId, TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM']),
|
||||
data: {
|
||||
url: data.url,
|
||||
name: data.name,
|
||||
},
|
||||
});
|
||||
|
||||
return team;
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import type { DocumentVisibility, Template, TemplateMeta } from '@prisma/client';
|
||||
|
||||
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
@ -39,6 +38,15 @@ export const updateTemplate = async ({
|
||||
},
|
||||
include: {
|
||||
templateMeta: true,
|
||||
team: {
|
||||
select: {
|
||||
organisation: {
|
||||
select: {
|
||||
organisationClaim: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -66,17 +74,10 @@ export const updateTemplate = async ({
|
||||
data?.globalActionAuth === undefined ? documentGlobalActionAuth : data.globalActionAuth;
|
||||
|
||||
// Check if user has permission to set the global action auth.
|
||||
if (newGlobalActionAuth) {
|
||||
const isDocumentEnterprise = await isUserEnterprise({
|
||||
userId,
|
||||
teamId,
|
||||
if (newGlobalActionAuth && !template.team.organisation.organisationClaim.flags.cfr21) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to set the action auth',
|
||||
});
|
||||
|
||||
if (!isDocumentEnterprise) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to set the action auth',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const authOptions = createDocumentAuthOptions({
|
||||
|
||||
@ -1,22 +0,0 @@
|
||||
import type { User } from '@prisma/client';
|
||||
|
||||
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
|
||||
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
|
||||
import { IS_BILLING_ENABLED, NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
|
||||
export type CreateBillingPortalOptions = {
|
||||
user: Pick<User, 'id' | 'customerId' | 'email' | 'name'>;
|
||||
};
|
||||
|
||||
export const createBillingPortal = async ({ user }: CreateBillingPortalOptions) => {
|
||||
if (!IS_BILLING_ENABLED()) {
|
||||
throw new Error('Billing is not enabled');
|
||||
}
|
||||
|
||||
const { stripeCustomer } = await getStripeCustomerByUser(user);
|
||||
|
||||
return getPortalSession({
|
||||
customerId: stripeCustomer.id,
|
||||
returnUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/settings/billing`,
|
||||
});
|
||||
};
|
||||
@ -1,39 +0,0 @@
|
||||
import type { User } from '@prisma/client';
|
||||
|
||||
import { getCheckoutSession } from '@documenso/ee/server-only/stripe/get-checkout-session';
|
||||
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
|
||||
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
|
||||
import { getSubscriptionsByUserId } from '../subscription/get-subscriptions-by-user-id';
|
||||
|
||||
export type CreateCheckoutSession = {
|
||||
user: Pick<User, 'id' | 'customerId' | 'email' | 'name'>;
|
||||
priceId: string;
|
||||
};
|
||||
|
||||
export const createCheckoutSession = async ({ user, priceId }: CreateCheckoutSession) => {
|
||||
const { stripeCustomer } = await getStripeCustomerByUser(user);
|
||||
|
||||
const existingSubscriptions = await getSubscriptionsByUserId({ userId: user.id });
|
||||
|
||||
const foundSubscription = existingSubscriptions.find(
|
||||
(subscription) =>
|
||||
subscription.priceId === priceId &&
|
||||
subscription.periodEnd &&
|
||||
subscription.periodEnd >= new Date(),
|
||||
);
|
||||
|
||||
if (foundSubscription) {
|
||||
return getPortalSession({
|
||||
customerId: stripeCustomer.id,
|
||||
returnUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/settings/billing`,
|
||||
});
|
||||
}
|
||||
|
||||
return getCheckoutSession({
|
||||
customerId: stripeCustomer.id,
|
||||
priceId,
|
||||
returnUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/settings/billing`,
|
||||
});
|
||||
};
|
||||
@ -1,10 +1,8 @@
|
||||
import { hash } from '@node-rs/bcrypt';
|
||||
import type { User } from '@prisma/client';
|
||||
import { OrganisationGroupType, OrganisationMemberInviteStatus } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { IS_BILLING_ENABLED } from '../../constants/app';
|
||||
import { SALT_ROUNDS } from '../../constants/auth';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { createPersonalOrganisation } from '../organisation/create-organisation';
|
||||
@ -14,16 +12,9 @@ export interface CreateUserOptions {
|
||||
email: string;
|
||||
password: string;
|
||||
signature?: string | null;
|
||||
orgUrl: string;
|
||||
}
|
||||
|
||||
export const createUser = async ({
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
signature,
|
||||
orgUrl,
|
||||
}: CreateUserOptions) => {
|
||||
export const createUser = async ({ name, email, password, signature }: CreateUserOptions) => {
|
||||
const hashedPassword = await hash(password, SALT_ROUNDS);
|
||||
|
||||
const userExists = await prisma.user.findFirst({
|
||||
@ -36,22 +27,6 @@ export const createUser = async ({
|
||||
throw new AppError(AppErrorCode.ALREADY_EXISTS);
|
||||
}
|
||||
|
||||
// Todo: orgs handle htis
|
||||
if (orgUrl) {
|
||||
const urlExists = await prisma.team.findFirst({
|
||||
where: {
|
||||
url: orgUrl,
|
||||
},
|
||||
});
|
||||
|
||||
if (urlExists) {
|
||||
throw new AppError('PROFILE_URL_TAKEN', {
|
||||
message: 'Profile username is taken',
|
||||
userMessage: 'The profile username is already taken',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const user = await prisma.$transaction(async (tx) => {
|
||||
const user = await tx.user.create({
|
||||
data: {
|
||||
@ -76,8 +51,7 @@ export const createUser = async ({
|
||||
return user;
|
||||
});
|
||||
|
||||
await createPersonalOrganisation({ userId: user.id, orgUrl });
|
||||
|
||||
// Not used at the moment, uncomment if required.
|
||||
await onCreateUserHook(user).catch((err) => {
|
||||
// Todo: (RR7) Add logging.
|
||||
console.error(err);
|
||||
@ -87,119 +61,12 @@ export const createUser = async ({
|
||||
};
|
||||
|
||||
/**
|
||||
* Should be run after a user is created.
|
||||
* Should be run after a user is created, example during email password signup or google sign in.
|
||||
*
|
||||
* @returns User
|
||||
*/
|
||||
export const onCreateUserHook = async (user: User) => {
|
||||
const { email } = user;
|
||||
|
||||
const acceptedOrganisationInvites = await prisma.organisationMemberInvite.findMany({
|
||||
where: {
|
||||
status: OrganisationMemberInviteStatus.ACCEPTED,
|
||||
email: {
|
||||
equals: email,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
include: {
|
||||
organisation: {
|
||||
include: {
|
||||
groups: {
|
||||
where: {
|
||||
type: OrganisationGroupType.INTERNAL_ORGANISATION,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// For each team invite, add the user to the organisation and team, then delete the team invite.
|
||||
// If an error occurs, reset the invitation to not accepted.
|
||||
await Promise.allSettled(
|
||||
acceptedOrganisationInvites.map(async (invite) =>
|
||||
prisma
|
||||
.$transaction(
|
||||
async (tx) => {
|
||||
const organisationGroupToUse = invite.organisation.groups.find(
|
||||
(group) =>
|
||||
group.type === OrganisationGroupType.INTERNAL_ORGANISATION &&
|
||||
group.organisationRole === invite.organisationRole,
|
||||
);
|
||||
|
||||
if (!organisationGroupToUse) {
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||
message: 'Organisation group not found',
|
||||
});
|
||||
}
|
||||
|
||||
await tx.organisationMember.create({
|
||||
data: {
|
||||
organisationId: invite.organisationId,
|
||||
userId: user.id,
|
||||
organisationGroupMembers: {
|
||||
create: {
|
||||
groupId: organisationGroupToUse.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await tx.organisationMemberInvite.delete({
|
||||
where: {
|
||||
id: invite.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!IS_BILLING_ENABLED()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const organisation = await tx.organisation.findFirstOrThrow({
|
||||
where: {
|
||||
id: invite.organisationId,
|
||||
},
|
||||
include: {
|
||||
members: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
subscriptions: {
|
||||
select: {
|
||||
id: true,
|
||||
priceId: true,
|
||||
planId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// const organisationSeatSubscription = // TODO
|
||||
|
||||
// if (organisation.subscriptions) {
|
||||
// await updateSubscriptionItemQuantity({
|
||||
// priceId: team.subscription.priceId,
|
||||
// subscriptionId: team.subscription.planId,
|
||||
// quantity: team.members.length,
|
||||
// });
|
||||
// }
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.catch(async () => {
|
||||
await prisma.organisationMemberInvite.update({
|
||||
where: {
|
||||
id: invite.id,
|
||||
},
|
||||
data: {
|
||||
status: OrganisationMemberInviteStatus.PENDING,
|
||||
},
|
||||
});
|
||||
}),
|
||||
),
|
||||
);
|
||||
await createPersonalOrganisation({ userId: user.id });
|
||||
|
||||
return user;
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,12 +1,8 @@
|
||||
import type { z } from 'zod';
|
||||
|
||||
import OrganisationClaimSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationClaimSchema';
|
||||
import { OrganisationSchema } from '@documenso/prisma/generated/zod/modelSchema/OrganisationSchema';
|
||||
|
||||
/**
|
||||
* The full document response schema.
|
||||
*
|
||||
* Mainly used for returning a single document from the API.
|
||||
*/
|
||||
export const ZOrganisationSchema = OrganisationSchema.pick({
|
||||
id: true,
|
||||
createdAt: true,
|
||||
@ -17,54 +13,20 @@ export const ZOrganisationSchema = OrganisationSchema.pick({
|
||||
customerId: true,
|
||||
ownerUserId: true,
|
||||
}).extend({
|
||||
// // Todo: Maybe we want to alter this a bit since this returns a lot of data.
|
||||
// documentData: OrganisationDataSchema.pick({
|
||||
// type: true,
|
||||
// id: true,
|
||||
// data: true,
|
||||
// initialData: true,
|
||||
// }),
|
||||
// documentMeta: OrganisationMetaSchema.pick({
|
||||
// signingOrder: true,
|
||||
// distributionMethod: true,
|
||||
// id: true,
|
||||
// subject: true,
|
||||
// message: true,
|
||||
// timezone: true,
|
||||
// password: true,
|
||||
// dateFormat: true,
|
||||
// documentId: true,
|
||||
// redirectUrl: true,
|
||||
// typedSignatureEnabled: true,
|
||||
// uploadSignatureEnabled: true,
|
||||
// drawSignatureEnabled: true,
|
||||
// allowDictateNextSigner: true,
|
||||
// language: true,
|
||||
// emailSettings: true,
|
||||
// }).nullable(),
|
||||
// recipients: ZRecipientLiteSchema.array(),
|
||||
// fields: ZFieldSchema.array(),
|
||||
organisationClaim: OrganisationClaimSchema.pick({
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
originalSubscriptionClaimId: true,
|
||||
teamCount: true,
|
||||
memberCount: true,
|
||||
flags: true,
|
||||
}),
|
||||
});
|
||||
|
||||
export type TOrganisation = z.infer<typeof ZOrganisationSchema>;
|
||||
|
||||
/**
|
||||
* A lite version of the document response schema without relations.
|
||||
*/
|
||||
export const ZOrganisationLiteSchema = OrganisationSchema.pick({
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
name: true,
|
||||
avatarImageId: true,
|
||||
customerId: true,
|
||||
ownerUserId: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* A version of the document response schema when returning multiple documents at once from a single API endpoint.
|
||||
*/
|
||||
export const ZOrganisationManySchema = OrganisationSchema.pick({
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
@ -73,15 +35,9 @@ export const ZOrganisationManySchema = OrganisationSchema.pick({
|
||||
avatarImageId: true,
|
||||
customerId: true,
|
||||
ownerUserId: true,
|
||||
}).extend({
|
||||
// user: UserSchema.pick({
|
||||
// id: true,
|
||||
// name: true,
|
||||
// email: true,
|
||||
// }),
|
||||
// recipients: ZRecipientLiteSchema.array(),
|
||||
// team: TeamSchema.pick({
|
||||
// id: true,
|
||||
// url: true,
|
||||
// }).nullable(),
|
||||
});
|
||||
|
||||
/**
|
||||
* A version of the organisation response schema when returning multiple organisations at once from a single API endpoint.
|
||||
*/
|
||||
export const ZOrganisationManySchema = ZOrganisationLiteSchema;
|
||||
|
||||
177
packages/lib/types/subscription.ts
Normal file
177
packages/lib/types/subscription.ts
Normal file
@ -0,0 +1,177 @@
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import type { SubscriptionClaim } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZOrganisationNameSchema } from '@documenso/trpc/server/organisation-router/create-organisation.types';
|
||||
|
||||
export const ZClaimFlagsSchema = z.object({
|
||||
unlimitedDocuments: z.boolean().optional(),
|
||||
|
||||
/**
|
||||
* Allows disabling of Documenso branding for:
|
||||
* - Certificates
|
||||
* - Emails
|
||||
* - Todo: orgs
|
||||
*
|
||||
* Rename to allowCustomBranding
|
||||
*/
|
||||
branding: z.boolean().optional(),
|
||||
|
||||
embedAuthoring: z.boolean().optional(),
|
||||
embedAuthoringWhiteLabel: z.boolean().optional(),
|
||||
|
||||
embedSigning: z.boolean().optional(),
|
||||
embedSigningWhiteLabel: z.boolean().optional(),
|
||||
|
||||
cfr21: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type TClaimFlags = z.infer<typeof ZClaimFlagsSchema>;
|
||||
|
||||
// When adding keys, update internal documentation with this.
|
||||
export const SUBSCRIPTION_CLAIM_FEATURE_FLAGS: Record<
|
||||
keyof TClaimFlags,
|
||||
{
|
||||
label: string;
|
||||
key: keyof TClaimFlags;
|
||||
}
|
||||
> = {
|
||||
unlimitedDocuments: {
|
||||
key: 'unlimitedDocuments',
|
||||
label: 'Unlimited documents',
|
||||
},
|
||||
branding: {
|
||||
key: 'branding',
|
||||
label: 'Branding',
|
||||
},
|
||||
embedAuthoring: {
|
||||
key: 'embedAuthoring',
|
||||
label: 'Embed authoring',
|
||||
},
|
||||
embedSigning: {
|
||||
key: 'embedSigning',
|
||||
label: 'Embed signing',
|
||||
},
|
||||
embedAuthoringWhiteLabel: {
|
||||
key: 'embedAuthoringWhiteLabel',
|
||||
label: 'White label for embed authoring',
|
||||
},
|
||||
embedSigningWhiteLabel: {
|
||||
key: 'embedSigningWhiteLabel',
|
||||
label: 'White label for embed signing',
|
||||
},
|
||||
cfr21: {
|
||||
key: 'cfr21',
|
||||
label: '21 CFR',
|
||||
},
|
||||
};
|
||||
|
||||
export enum INTERNAL_CLAIM_ID {
|
||||
FREE = 'free',
|
||||
INDIVIDUAL = 'individual',
|
||||
PRO = 'pro',
|
||||
EARLY_ADOPTER = 'earlyAdopter',
|
||||
PLATFORM = 'platform',
|
||||
ENTERPRISE = 'enterprise',
|
||||
}
|
||||
|
||||
export type InternalClaim = Omit<SubscriptionClaim, 'createdAt' | 'updatedAt'> & {
|
||||
description: MessageDescriptor | string;
|
||||
};
|
||||
|
||||
export type InternalClaims = {
|
||||
[key in INTERNAL_CLAIM_ID]: InternalClaim;
|
||||
};
|
||||
|
||||
export const internalClaims: InternalClaims = {
|
||||
[INTERNAL_CLAIM_ID.FREE]: {
|
||||
id: INTERNAL_CLAIM_ID.FREE,
|
||||
name: 'Free',
|
||||
description: msg`5 Documents a month`,
|
||||
teamCount: 1,
|
||||
memberCount: 1,
|
||||
locked: true,
|
||||
flags: {},
|
||||
},
|
||||
[INTERNAL_CLAIM_ID.INDIVIDUAL]: {
|
||||
id: INTERNAL_CLAIM_ID.INDIVIDUAL,
|
||||
name: 'Individual',
|
||||
description: msg`Unlimited documents, API and more`,
|
||||
teamCount: 1,
|
||||
memberCount: 1,
|
||||
locked: true,
|
||||
flags: {
|
||||
unlimitedDocuments: true,
|
||||
},
|
||||
},
|
||||
[INTERNAL_CLAIM_ID.PRO]: {
|
||||
id: INTERNAL_CLAIM_ID.PRO, // Team -> Pro
|
||||
name: 'Teams',
|
||||
description: msg`Embedding, 5 members included and more`,
|
||||
teamCount: 1,
|
||||
memberCount: 5,
|
||||
locked: true,
|
||||
flags: {
|
||||
unlimitedDocuments: true,
|
||||
branding: true,
|
||||
embedSigning: true, // Pro (team) plan only gets embedSigning right?
|
||||
},
|
||||
},
|
||||
[INTERNAL_CLAIM_ID.PLATFORM]: {
|
||||
id: INTERNAL_CLAIM_ID.PLATFORM,
|
||||
name: 'Platform',
|
||||
description: msg`Whitelabeling, unlimited members and more`,
|
||||
teamCount: 1,
|
||||
memberCount: 0,
|
||||
locked: true,
|
||||
flags: {
|
||||
unlimitedDocuments: true,
|
||||
branding: true,
|
||||
embedAuthoring: false,
|
||||
embedAuthoringWhiteLabel: true,
|
||||
embedSigning: false,
|
||||
embedSigningWhiteLabel: true,
|
||||
},
|
||||
},
|
||||
[INTERNAL_CLAIM_ID.ENTERPRISE]: {
|
||||
id: INTERNAL_CLAIM_ID.ENTERPRISE,
|
||||
name: 'Enterprise',
|
||||
description: '',
|
||||
teamCount: 0,
|
||||
memberCount: 0,
|
||||
locked: true,
|
||||
flags: {
|
||||
unlimitedDocuments: true,
|
||||
branding: true,
|
||||
embedAuthoring: true,
|
||||
embedAuthoringWhiteLabel: true,
|
||||
embedSigning: true,
|
||||
embedSigningWhiteLabel: true,
|
||||
cfr21: true,
|
||||
},
|
||||
},
|
||||
[INTERNAL_CLAIM_ID.EARLY_ADOPTER]: {
|
||||
id: INTERNAL_CLAIM_ID.EARLY_ADOPTER,
|
||||
name: 'Early Adopter',
|
||||
description: '',
|
||||
teamCount: 0,
|
||||
memberCount: 0,
|
||||
locked: true,
|
||||
flags: {
|
||||
unlimitedDocuments: true,
|
||||
branding: true,
|
||||
embedSigning: true,
|
||||
embedSigningWhiteLabel: true,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const ZStripeOrganisationCreateMetadataSchema = z.object({
|
||||
organisationName: ZOrganisationNameSchema,
|
||||
userId: z.number(),
|
||||
});
|
||||
|
||||
export type StripeOrganisationCreateMetadata = z.infer<
|
||||
typeof ZStripeOrganisationCreateMetadataSchema
|
||||
>;
|
||||
@ -3,3 +3,7 @@ import { customAlphabet } from 'nanoid';
|
||||
export const alphaid = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 21);
|
||||
|
||||
export { nanoid } from 'nanoid';
|
||||
|
||||
export const generatePrefixedId = (prefix: string, length = 8) => {
|
||||
return `${prefix}_${alphaid(length)}`;
|
||||
};
|
||||
|
||||
@ -1,36 +1,39 @@
|
||||
import type { Subscription } from '@prisma/client';
|
||||
import { SubscriptionStatus } from '@prisma/client';
|
||||
import type { Subscription } from '@documenso/prisma/generated/zod/modelSchema/SubscriptionSchema';
|
||||
|
||||
import { IS_BILLING_ENABLED } from '../constants/app';
|
||||
import { AppErrorCode } from '../errors/app-error';
|
||||
import { AppError } from '../errors/app-error';
|
||||
import type { StripeOrganisationCreateMetadata } from '../types/subscription';
|
||||
|
||||
export const generateStripeOrganisationCreateMetadata = (
|
||||
organisationName: string,
|
||||
userId: number,
|
||||
) => {
|
||||
const metadata: StripeOrganisationCreateMetadata = {
|
||||
organisationName,
|
||||
userId,
|
||||
};
|
||||
|
||||
return {
|
||||
organisationCreateData: JSON.stringify(metadata),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns true if there is a subscription that is active and is one of the provided price IDs.
|
||||
* Throws an error if billing is enabled and no subscription is found.
|
||||
*/
|
||||
export const subscriptionsContainsActivePlan = (
|
||||
subscriptions: Subscription[],
|
||||
priceIds: string[],
|
||||
allowPastDue?: boolean,
|
||||
) => {
|
||||
const allowedSubscriptionStatuses: SubscriptionStatus[] = [SubscriptionStatus.ACTIVE];
|
||||
export const validateIfSubscriptionIsRequired = (subscription?: Subscription | null) => {
|
||||
const isBillingEnabled = IS_BILLING_ENABLED();
|
||||
|
||||
if (allowPastDue) {
|
||||
allowedSubscriptionStatuses.push(SubscriptionStatus.PAST_DUE);
|
||||
if (!isBillingEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
return subscriptions.some(
|
||||
(subscription) =>
|
||||
allowedSubscriptionStatuses.includes(subscription.status) &&
|
||||
priceIds.includes(subscription.priceId),
|
||||
);
|
||||
};
|
||||
if (isBillingEnabled && !subscription) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Subscription not found',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if there is a subscription that is active and is one of the provided product IDs.
|
||||
*/
|
||||
export const subscriptionsContainsActiveProductId = (
|
||||
subscriptions: Subscription[],
|
||||
productId: string[],
|
||||
) => {
|
||||
return subscriptions.some(
|
||||
(subscription) =>
|
||||
subscription.status === SubscriptionStatus.ACTIVE && productId.includes(subscription.planId),
|
||||
);
|
||||
return subscription;
|
||||
};
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { I18n, MessageDescriptor } from '@lingui/core';
|
||||
import { i18n } from '@lingui/core';
|
||||
import type { MacroMessageDescriptor } from '@lingui/core/macro';
|
||||
|
||||
import type { I18nLocaleData, SupportedLanguageCodes } from '../constants/i18n';
|
||||
import { APP_I18N_OPTIONS } from '../constants/i18n';
|
||||
@ -84,3 +85,10 @@ export const extractLocaleData = ({ headers }: ExtractLocaleDataOptions): I18nLo
|
||||
export const parseMessageDescriptor = (_: I18n['_'], value: string | MessageDescriptor) => {
|
||||
return typeof value === 'string' ? value : _(value);
|
||||
};
|
||||
|
||||
export const parseMessageDescriptorMacro = (
|
||||
t: (descriptor: MacroMessageDescriptor) => string,
|
||||
value: string | MessageDescriptor,
|
||||
) => {
|
||||
return typeof value === 'string' ? value : t(value);
|
||||
};
|
||||
|
||||
25
packages/lib/utils/organisations-claims.ts
Normal file
25
packages/lib/utils/organisations-claims.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import type { OrganisationClaim, SubscriptionClaim } from '@prisma/client';
|
||||
|
||||
export const generateDefaultOrganisationClaims = (): Omit<
|
||||
OrganisationClaim,
|
||||
'id' | 'organisation' | 'createdAt' | 'updatedAt' | 'originalSubscriptionClaimId'
|
||||
> => {
|
||||
return {
|
||||
teamCount: 1,
|
||||
memberCount: 1,
|
||||
flags: {},
|
||||
};
|
||||
};
|
||||
|
||||
export const generateDefaultSubscriptionClaim = (): Omit<
|
||||
SubscriptionClaim,
|
||||
'id' | 'organisation' | 'createdAt' | 'updatedAt' | 'originalSubscriptionClaimId'
|
||||
> => {
|
||||
return {
|
||||
name: '',
|
||||
teamCount: 1,
|
||||
memberCount: 1,
|
||||
locked: false,
|
||||
flags: {},
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,69 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `customerId` on the `User` table. All the data in the column will be lost.
|
||||
- You are about to drop the `TeamPending` table. If the table is not empty, all the data it contains will be lost.
|
||||
- A unique constraint covering the columns `[organisationClaimId]` on the table `Organisation` will be added. If there are existing duplicate values, this will fail.
|
||||
- A unique constraint covering the columns `[organisationId]` on the table `Subscription` will be added. If there are existing duplicate values, this will fail.
|
||||
- Added the required column `organisationClaimId` to the `Organisation` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Subscription" DROP CONSTRAINT "Subscription_organisationId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "TeamPending" DROP CONSTRAINT "TeamPending_ownerUserId_fkey";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "User_customerId_key";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Organisation" ADD COLUMN "organisationClaimId" TEXT NOT NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Subscription" ADD COLUMN "customerId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" DROP COLUMN "customerId";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "TeamPending";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SubscriptionClaim" (
|
||||
"id" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"locked" BOOLEAN NOT NULL DEFAULT false,
|
||||
"teamCount" INTEGER NOT NULL,
|
||||
"memberCount" INTEGER NOT NULL,
|
||||
"flags" JSONB NOT NULL,
|
||||
|
||||
CONSTRAINT "SubscriptionClaim_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "OrganisationClaim" (
|
||||
"id" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"originalSubscriptionClaimId" TEXT,
|
||||
"teamCount" INTEGER NOT NULL,
|
||||
"memberCount" INTEGER NOT NULL,
|
||||
"flags" JSONB NOT NULL,
|
||||
|
||||
CONSTRAINT "OrganisationClaim_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Organisation_organisationClaimId_key" ON "Organisation"("organisationClaimId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Subscription_organisationId_key" ON "Subscription"("organisationId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "Organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Organisation" ADD CONSTRAINT "Organisation_organisationClaimId_fkey" FOREIGN KEY ("organisationClaimId") REFERENCES "OrganisationClaim"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@ -0,0 +1,9 @@
|
||||
-- Insert default claims
|
||||
INSERT INTO "SubscriptionClaim" (id, name, "teamCount", "memberCount", locked, flags, "createdAt", "updatedAt")
|
||||
VALUES
|
||||
('free', 'Free', 1, 1, true, '{}', NOW(), NOW()),
|
||||
('individual', 'Individual', 1, 1, true, '{"unlimitedDocuments": true}', NOW(), NOW()),
|
||||
('pro', 'Teams', 1, 5, true, '{"memberStripeSync": true, "unlimitedDocuments": true, "embedSigning": true}', NOW(), NOW()),
|
||||
('platform', 'Platform', 1, 0, true, '{"unlimitedDocuments": true, "embedAuthoring": false, "embedAuthoringWhiteLabel": true, "embedSigning": false, "embedSigningWhiteLabel": true}', NOW(), NOW()),
|
||||
('enterprise', 'Enterprise', 0, 0, true, '{"unlimitedDocuments": true, "embedAuthoring": true, "embedAuthoringWhiteLabel": true, "embedSigning": true, "embedSigningWhiteLabel": true, "cfr21": true}', NOW(), NOW()),
|
||||
('early-adopter', 'Early Adopter', 0, 0, true, '{"unlimitedDocuments": true, "embedAuthoring": false, "embedAuthoringWhiteLabel": true, "embedSigning": false, "embedSigningWhiteLabel": true}', NOW(), NOW());
|
||||
@ -1,3 +1,4 @@
|
||||
// Todo: orgs remember to add custom migration to apply default claims
|
||||
generator kysely {
|
||||
provider = "prisma-kysely"
|
||||
}
|
||||
@ -39,7 +40,6 @@ enum Role {
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
name String?
|
||||
customerId String? @unique
|
||||
email String @unique
|
||||
emailVerified DateTime?
|
||||
password String? // Todo: (RR7) Remove after RR7 migration.
|
||||
@ -60,8 +60,6 @@ model User {
|
||||
ownedOrganisations Organisation[]
|
||||
organisationMember OrganisationMember[]
|
||||
|
||||
ownedPendingTeams TeamPending[]
|
||||
|
||||
twoFactorSecret String?
|
||||
twoFactorEnabled Boolean @default(false)
|
||||
twoFactorBackupCodes String?
|
||||
@ -255,12 +253,44 @@ model Subscription {
|
||||
updatedAt DateTime @updatedAt
|
||||
cancelAtPeriodEnd Boolean @default(false)
|
||||
|
||||
organisationId String
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id])
|
||||
customerId String? // Todo: orgs
|
||||
|
||||
organisationId String @unique
|
||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([organisationId])
|
||||
}
|
||||
|
||||
/// @zod.import(["import { ZClaimFlagsSchema } from '@documenso/lib/types/subscription';"])
|
||||
model SubscriptionClaim {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
name String
|
||||
locked Boolean @default(false)
|
||||
|
||||
teamCount Int
|
||||
memberCount Int
|
||||
|
||||
flags Json /// [ClaimFlags] @zod.custom.use(ZClaimFlagsSchema)
|
||||
}
|
||||
|
||||
/// @zod.import(["import { ZClaimFlagsSchema } from '@documenso/lib/types/subscription';"])
|
||||
model OrganisationClaim {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
originalSubscriptionClaimId String?
|
||||
organisation Organisation?
|
||||
|
||||
teamCount Int
|
||||
memberCount Int
|
||||
|
||||
flags Json /// [ClaimFlags] @zod.custom.use(ZClaimFlagsSchema)
|
||||
}
|
||||
|
||||
model Account {
|
||||
id String @id @default(cuid())
|
||||
userId Int
|
||||
@ -550,8 +580,11 @@ model Organisation {
|
||||
url String @unique // todo: constrain
|
||||
avatarImageId String?
|
||||
|
||||
customerId String? @unique
|
||||
subscriptions Subscription[]
|
||||
customerId String? @unique // Todo: orgs
|
||||
subscription Subscription?
|
||||
|
||||
organisationClaimId String @unique
|
||||
organisationClaim OrganisationClaim @relation(fields: [organisationClaimId], references: [id], onDelete: Cascade)
|
||||
|
||||
members OrganisationMember[]
|
||||
invites OrganisationMemberInvite[]
|
||||
@ -683,7 +716,7 @@ model OrganisationGlobalSettings {
|
||||
brandingLogo String @default("")
|
||||
brandingUrl String @default("")
|
||||
brandingCompanyDetails String @default("")
|
||||
brandingHidePoweredBy Boolean @default(false)
|
||||
brandingHidePoweredBy Boolean @default(false) // Todo: orgs this doesn't seem to be used?
|
||||
}
|
||||
|
||||
model TeamGlobalSettings {
|
||||
@ -732,17 +765,6 @@ model Team {
|
||||
teamGlobalSettings TeamGlobalSettings @relation(fields: [teamGlobalSettingsId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model TeamPending {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
url String @unique
|
||||
createdAt DateTime @default(now())
|
||||
customerId String @unique
|
||||
ownerUserId Int
|
||||
|
||||
owner User @relation(fields: [ownerUserId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model TeamEmail {
|
||||
teamId Int @id @unique
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
3
packages/prisma/types/types.d.ts
vendored
3
packages/prisma/types/types.d.ts
vendored
@ -6,12 +6,15 @@ import type {
|
||||
import type { TDocumentEmailSettings } from '@documenso/lib/types/document-email';
|
||||
import type { TDocumentFormValues } from '@documenso/lib/types/document-form-values';
|
||||
import type { TFieldMetaNotOptionalSchema } from '@documenso/lib/types/field-meta';
|
||||
import type { TClaimFlags } from '@documenso/lib/types/subscription';
|
||||
|
||||
/**
|
||||
* Global types for Prisma.Json instances.
|
||||
*/
|
||||
declare global {
|
||||
namespace PrismaJson {
|
||||
type ClaimFlags = TClaimFlags;
|
||||
|
||||
type DocumentFormValues = TDocumentFormValues;
|
||||
type DocumentAuthOptions = TDocumentAuthOptions;
|
||||
type DocumentEmailSettings = TDocumentEmailSettings;
|
||||
|
||||
50
packages/trpc/server/admin-router/create-stripe-customer.ts
Normal file
50
packages/trpc/server/admin-router/create-stripe-customer.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { createCustomer } from '@documenso/ee/server-only/stripe/create-customer';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { adminProcedure } from '../trpc';
|
||||
import {
|
||||
ZCreateStripeCustomerRequestSchema,
|
||||
ZCreateStripeCustomerResponseSchema,
|
||||
} from './create-stripe-customer.types';
|
||||
|
||||
export const createStripeCustomerRoute = adminProcedure
|
||||
.input(ZCreateStripeCustomerRequestSchema)
|
||||
.output(ZCreateStripeCustomerResponseSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
const { organisationId } = input;
|
||||
|
||||
const organisation = await prisma.organisation.findUnique({
|
||||
where: {
|
||||
id: organisationId,
|
||||
},
|
||||
include: {
|
||||
owner: {
|
||||
select: {
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!organisation) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND);
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const stripeCustomer = await createCustomer({
|
||||
name: organisation.name,
|
||||
email: organisation.owner.email,
|
||||
});
|
||||
|
||||
await tx.organisation.update({
|
||||
where: {
|
||||
id: organisationId,
|
||||
},
|
||||
data: {
|
||||
customerId: stripeCustomer.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,9 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZCreateStripeCustomerRequestSchema = z.object({
|
||||
organisationId: z.string().describe('The organisation to attach the customer to'),
|
||||
});
|
||||
|
||||
export const ZCreateStripeCustomerResponseSchema = z.void();
|
||||
|
||||
export type TCreateStripeCustomerRequest = z.infer<typeof ZCreateStripeCustomerRequestSchema>;
|
||||
@ -0,0 +1,23 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { adminProcedure } from '../trpc';
|
||||
import {
|
||||
ZCreateSubscriptionClaimRequestSchema,
|
||||
ZCreateSubscriptionClaimResponseSchema,
|
||||
} from './create-subscription-claim.types';
|
||||
|
||||
export const createSubscriptionClaimRoute = adminProcedure
|
||||
.input(ZCreateSubscriptionClaimRequestSchema)
|
||||
.output(ZCreateSubscriptionClaimResponseSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
const { name, teamCount, memberCount, flags } = input;
|
||||
|
||||
await prisma.subscriptionClaim.create({
|
||||
data: {
|
||||
name,
|
||||
teamCount,
|
||||
memberCount,
|
||||
flags,
|
||||
},
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,14 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZClaimFlagsSchema } from '@documenso/lib/types/subscription';
|
||||
|
||||
export const ZCreateSubscriptionClaimRequestSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
teamCount: z.number().int().min(0),
|
||||
memberCount: z.number().int().min(0),
|
||||
flags: ZClaimFlagsSchema,
|
||||
});
|
||||
|
||||
export const ZCreateSubscriptionClaimResponseSchema = z.void();
|
||||
|
||||
export type TCreateSubscriptionClaimRequest = z.infer<typeof ZCreateSubscriptionClaimRequestSchema>;
|
||||
@ -0,0 +1,37 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { adminProcedure } from '../trpc';
|
||||
import {
|
||||
ZDeleteSubscriptionClaimRequestSchema,
|
||||
ZDeleteSubscriptionClaimResponseSchema,
|
||||
} from './delete-subscription-claim.types';
|
||||
|
||||
export const deleteSubscriptionClaimRoute = adminProcedure
|
||||
.input(ZDeleteSubscriptionClaimRequestSchema)
|
||||
.output(ZDeleteSubscriptionClaimResponseSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
const { id } = input;
|
||||
|
||||
const existingClaim = await prisma.subscriptionClaim.findFirst({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingClaim) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, { message: 'Subscription claim not found' });
|
||||
}
|
||||
|
||||
if (existingClaim.locked) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'Cannot delete locked subscription claim',
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.subscriptionClaim.delete({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,9 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZDeleteSubscriptionClaimRequestSchema = z.object({
|
||||
id: z.string().cuid(),
|
||||
});
|
||||
|
||||
export const ZDeleteSubscriptionClaimResponseSchema = z.void();
|
||||
|
||||
export type TDeleteSubscriptionClaimRequest = z.infer<typeof ZDeleteSubscriptionClaimRequestSchema>;
|
||||
128
packages/trpc/server/admin-router/find-admin-organisations.ts
Normal file
128
packages/trpc/server/admin-router/find-admin-organisations.ts
Normal file
@ -0,0 +1,128 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
import type { FindResultResponse } from '@documenso/lib/types/search-params';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { adminProcedure } from '../trpc';
|
||||
import {
|
||||
ZFindAdminOrganisationsRequestSchema,
|
||||
ZFindAdminOrganisationsResponseSchema,
|
||||
} from './find-admin-organisations.types';
|
||||
|
||||
export const findAdminOrganisationsRoute = adminProcedure
|
||||
.input(ZFindAdminOrganisationsRequestSchema)
|
||||
.output(ZFindAdminOrganisationsResponseSchema)
|
||||
.query(async ({ input }) => {
|
||||
const { query, page, perPage } = input;
|
||||
|
||||
return await findAdminOrganisations({
|
||||
query,
|
||||
page,
|
||||
perPage,
|
||||
});
|
||||
});
|
||||
|
||||
type FindAdminOrganisationsOptions = {
|
||||
query?: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
};
|
||||
|
||||
export const findAdminOrganisations = async ({
|
||||
query,
|
||||
page = 1,
|
||||
perPage = 10,
|
||||
}: FindAdminOrganisationsOptions) => {
|
||||
let whereClause: Prisma.OrganisationWhereInput = {};
|
||||
|
||||
if (query) {
|
||||
whereClause = {
|
||||
OR: [
|
||||
{
|
||||
id: {
|
||||
contains: query,
|
||||
mode: Prisma.QueryMode.insensitive,
|
||||
},
|
||||
},
|
||||
{
|
||||
owner: {
|
||||
email: {
|
||||
contains: query,
|
||||
mode: Prisma.QueryMode.insensitive,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
customerId: {
|
||||
contains: query,
|
||||
mode: Prisma.QueryMode.insensitive,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: {
|
||||
contains: query,
|
||||
mode: Prisma.QueryMode.insensitive,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (query && query.startsWith('claim:')) {
|
||||
whereClause = {
|
||||
organisationClaim: {
|
||||
originalSubscriptionClaimId: {
|
||||
contains: query.slice(6),
|
||||
mode: Prisma.QueryMode.insensitive,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (query && query.startsWith('org_')) {
|
||||
whereClause = {
|
||||
url: {
|
||||
equals: query,
|
||||
mode: Prisma.QueryMode.insensitive,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const [data, count] = await Promise.all([
|
||||
prisma.organisation.findMany({
|
||||
where: whereClause,
|
||||
skip: Math.max(page - 1, 0) * perPage,
|
||||
take: perPage,
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
name: true,
|
||||
url: true,
|
||||
customerId: true,
|
||||
owner: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
subscription: true,
|
||||
},
|
||||
}),
|
||||
prisma.organisation.count({
|
||||
where: whereClause,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
data,
|
||||
count,
|
||||
currentPage: Math.max(page, 1),
|
||||
perPage,
|
||||
totalPages: Math.ceil(count / perPage),
|
||||
} satisfies FindResultResponse<typeof data>;
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user