feat: add organisations (#1820)

This commit is contained in:
David Nguyen
2025-06-10 11:49:52 +10:00
committed by GitHub
parent 0b37f19641
commit e6dc237ad2
631 changed files with 37616 additions and 25695 deletions

View File

@ -0,0 +1,28 @@
import { OrganisationType } from '@prisma/client';
import { createOrganisation } from '@documenso/lib/server-only/organisation/create-organisation';
import { internalClaims } from '@documenso/lib/types/subscription';
import { adminProcedure } from '../trpc';
import {
ZCreateAdminOrganisationRequestSchema,
ZCreateAdminOrganisationResponseSchema,
} from './create-admin-organisation.types';
export const createAdminOrganisationRoute = adminProcedure
.input(ZCreateAdminOrganisationRequestSchema)
.output(ZCreateAdminOrganisationResponseSchema)
.mutation(async ({ input }) => {
const { ownerUserId, data } = input;
const organisation = await createOrganisation({
userId: ownerUserId,
name: data.name,
type: OrganisationType.ORGANISATION,
claim: internalClaims.free,
});
return {
organisationId: organisation.id,
};
});

View File

@ -0,0 +1,16 @@
import { z } from 'zod';
import { ZOrganisationNameSchema } from '../organisation-router/create-organisation.types';
export const ZCreateAdminOrganisationRequestSchema = z.object({
ownerUserId: z.number(),
data: z.object({
name: ZOrganisationNameSchema,
}),
});
export const ZCreateAdminOrganisationResponseSchema = z.object({
organisationId: z.string(),
});
export type TCreateAdminOrganisationRequest = z.infer<typeof ZCreateAdminOrganisationRequestSchema>;

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,160 @@
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, ownerUserId, memberUserId } = input;
return await findAdminOrganisations({
query,
page,
perPage,
ownerUserId,
memberUserId,
});
});
type FindAdminOrganisationsOptions = {
query?: string;
page?: number;
perPage?: number;
ownerUserId?: number;
memberUserId?: number;
};
export const findAdminOrganisations = async ({
query,
page = 1,
perPage = 10,
ownerUserId,
memberUserId,
}: 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 = {
OR: [
{
id: {
equals: query,
mode: Prisma.QueryMode.insensitive,
},
},
{
url: {
equals: query,
mode: Prisma.QueryMode.insensitive,
},
},
],
};
}
if (ownerUserId) {
whereClause = {
...whereClause,
ownerUserId,
};
}
if (memberUserId) {
whereClause = {
...whereClause,
members: {
some: { userId: memberUserId },
},
};
}
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>;
};

View File

@ -0,0 +1,42 @@
import { z } from 'zod';
import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
import OrganisationSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationSchema';
import SubscriptionSchema from '@documenso/prisma/generated/zod/modelSchema/SubscriptionSchema';
import UserSchema from '@documenso/prisma/generated/zod/modelSchema/UserSchema';
export const ZFindAdminOrganisationsRequestSchema = ZFindSearchParamsSchema.extend({
ownerUserId: z.number().optional(),
memberUserId: z.number().optional(),
});
export const ZFindAdminOrganisationsResponseSchema = ZFindResultResponse.extend({
data: OrganisationSchema.pick({
id: true,
createdAt: true,
updatedAt: true,
name: true,
url: true,
customerId: true,
})
.extend({
owner: UserSchema.pick({
id: true,
email: true,
name: true,
}),
subscription: SubscriptionSchema.pick({
status: true,
id: true,
planId: true,
priceId: true,
periodEnd: true,
createdAt: true,
updatedAt: true,
cancelAtPeriodEnd: true,
}).nullable(),
})
.array(),
});
export type TFindAdminOrganisationsResponse = z.infer<typeof ZFindAdminOrganisationsResponseSchema>;

View File

@ -0,0 +1,76 @@
import type { Prisma } from '@prisma/client';
import type { z } from 'zod';
import type { FindResultResponse } from '@documenso/lib/types/search-params';
import { prisma } from '@documenso/prisma';
import type SubscriptionClaimSchema from '@documenso/prisma/generated/zod/modelSchema/SubscriptionClaimSchema';
import { adminProcedure } from '../trpc';
import {
ZFindSubscriptionClaimsRequestSchema,
ZFindSubscriptionClaimsResponseSchema,
} from './find-subscription-claims.types';
export const findSubscriptionClaimsRoute = adminProcedure
.input(ZFindSubscriptionClaimsRequestSchema)
.output(ZFindSubscriptionClaimsResponseSchema)
.query(async ({ input }) => {
const { query, page, perPage } = input;
return await findSubscriptionClaims({ query, page, perPage });
});
type FindSubscriptionClaimsOptions = {
query?: string;
page?: number;
perPage?: number;
};
export const findSubscriptionClaims = async ({
query,
page = 1,
perPage = 50,
}: FindSubscriptionClaimsOptions) => {
let whereClause: Prisma.SubscriptionClaimWhereInput = {};
if (query) {
whereClause = {
OR: [
{
id: {
contains: query,
mode: 'insensitive',
},
},
{
name: {
contains: query,
mode: 'insensitive',
},
},
],
};
}
const [data, count] = await Promise.all([
prisma.subscriptionClaim.findMany({
where: whereClause,
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
orderBy: {
name: 'asc',
},
}),
prisma.subscriptionClaim.count({
where: whereClause,
}),
]);
return {
data,
count,
currentPage: Math.max(page, 1),
perPage,
totalPages: Math.ceil(count / perPage),
} satisfies FindResultResponse<z.infer<typeof SubscriptionClaimSchema>[]>;
};

View File

@ -0,0 +1,22 @@
import type { z } from 'zod';
import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
import SubscriptionClaimSchema from '@documenso/prisma/generated/zod/modelSchema/SubscriptionClaimSchema';
export const ZFindSubscriptionClaimsRequestSchema = ZFindSearchParamsSchema.extend({});
export const ZFindSubscriptionClaimsResponseSchema = ZFindResultResponse.extend({
data: SubscriptionClaimSchema.pick({
id: true,
createdAt: true,
updatedAt: true,
name: true,
teamCount: true,
memberCount: true,
locked: true,
flags: true,
}).array(),
});
export type TFindSubscriptionClaimsRequest = z.infer<typeof ZFindSubscriptionClaimsRequestSchema>;
export type TFindSubscriptionClaimsResponse = z.infer<typeof ZFindSubscriptionClaimsResponseSchema>;

View File

@ -0,0 +1,56 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { adminProcedure } from '../trpc';
import {
ZGetAdminOrganisationRequestSchema,
ZGetAdminOrganisationResponseSchema,
} from './get-admin-organisation.types';
export const getAdminOrganisationRoute = adminProcedure
.input(ZGetAdminOrganisationRequestSchema)
.output(ZGetAdminOrganisationResponseSchema)
.query(async ({ input }) => {
const { organisationId } = input;
return await getAdminOrganisation({
organisationId,
});
});
type GetOrganisationOptions = {
organisationId: string;
};
export const getAdminOrganisation = async ({ organisationId }: GetOrganisationOptions) => {
const organisation = await prisma.organisation.findFirst({
where: {
id: organisationId,
},
include: {
organisationClaim: true,
organisationGlobalSettings: true,
teams: true,
members: {
include: {
user: {
select: {
id: true,
email: true,
name: true,
},
},
},
},
subscription: true,
},
});
if (!organisation) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Organisation not found',
});
}
return organisation;
};

View File

@ -0,0 +1,38 @@
import { z } from 'zod';
import { ZOrganisationSchema } from '@documenso/lib/types/organisation';
import OrganisationClaimSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationClaimSchema';
import OrganisationGlobalSettingsSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationGlobalSettingsSchema';
import OrganisationMemberSchema from '@documenso/prisma/generated/zod/modelSchema/OrganisationMemberSchema';
import SubscriptionSchema from '@documenso/prisma/generated/zod/modelSchema/SubscriptionSchema';
import TeamSchema from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
import UserSchema from '@documenso/prisma/generated/zod/modelSchema/UserSchema';
export const ZGetAdminOrganisationRequestSchema = z.object({
organisationId: z.string(),
});
export const ZGetAdminOrganisationResponseSchema = ZOrganisationSchema.extend({
organisationGlobalSettings: OrganisationGlobalSettingsSchema,
teams: z.array(
TeamSchema.pick({
id: true,
name: true,
url: true,
createdAt: true,
avatarImageId: true,
organisationId: true,
}),
),
members: OrganisationMemberSchema.extend({
user: UserSchema.pick({
id: true,
email: true,
name: true,
}),
}).array(),
subscription: SubscriptionSchema.nullable(),
organisationClaim: OrganisationClaimSchema,
});
export type TGetAdminOrganisationResponse = z.infer<typeof ZGetAdminOrganisationResponseSchema>;

View File

@ -14,6 +14,13 @@ import { getUserById } from '@documenso/lib/server-only/user/get-user-by-id';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { adminProcedure, router } from '../trpc';
import { createAdminOrganisationRoute } from './create-admin-organisation';
import { createStripeCustomerRoute } from './create-stripe-customer';
import { createSubscriptionClaimRoute } from './create-subscription-claim';
import { deleteSubscriptionClaimRoute } from './delete-subscription-claim';
import { findAdminOrganisationsRoute } from './find-admin-organisations';
import { findSubscriptionClaimsRoute } from './find-subscription-claims';
import { getAdminOrganisationRoute } from './get-admin-organisation';
import {
ZAdminDeleteDocumentMutationSchema,
ZAdminDeleteUserMutationSchema,
@ -25,8 +32,27 @@ import {
ZAdminUpdateRecipientMutationSchema,
ZAdminUpdateSiteSettingMutationSchema,
} from './schema';
import { updateAdminOrganisationRoute } from './update-admin-organisation';
import { updateSubscriptionClaimRoute } from './update-subscription-claim';
export const adminRouter = router({
organisation: {
find: findAdminOrganisationsRoute,
get: getAdminOrganisationRoute,
create: createAdminOrganisationRoute,
update: updateAdminOrganisationRoute,
},
claims: {
find: findSubscriptionClaimsRoute,
create: createSubscriptionClaimRoute,
update: updateSubscriptionClaimRoute,
delete: deleteSubscriptionClaimRoute,
},
stripe: {
createCustomer: createStripeCustomerRoute,
},
// Todo: migrate old routes
findDocuments: adminProcedure.input(ZAdminFindDocumentsQuerySchema).query(async ({ input }) => {
const { query, page, perPage } = input;

View File

@ -0,0 +1,51 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { adminProcedure } from '../trpc';
import {
ZUpdateAdminOrganisationRequestSchema,
ZUpdateAdminOrganisationResponseSchema,
} from './update-admin-organisation.types';
export const updateAdminOrganisationRoute = adminProcedure
.input(ZUpdateAdminOrganisationRequestSchema)
.output(ZUpdateAdminOrganisationResponseSchema)
.mutation(async ({ input }) => {
const { organisationId, data } = input;
const organisation = await prisma.organisation.findUnique({
where: {
id: organisationId,
},
include: {
organisationClaim: true,
},
});
if (!organisation) {
throw new AppError(AppErrorCode.NOT_FOUND);
}
const { name, url, customerId, claims, originalSubscriptionClaimId } = data;
await prisma.organisation.update({
where: {
id: organisationId,
},
data: {
name,
url,
customerId: customerId ? customerId : undefined,
},
});
await prisma.organisationClaim.update({
where: {
id: organisation.organisationClaimId,
},
data: {
...claims,
originalSubscriptionClaimId,
},
});
});

View File

@ -0,0 +1,24 @@
import { z } from 'zod';
import { ZOrganisationNameSchema } from '../organisation-router/create-organisation.types';
import { ZTeamUrlSchema } from '../team-router/schema';
import { ZCreateSubscriptionClaimRequestSchema } from './create-subscription-claim.types';
export const ZUpdateAdminOrganisationRequestSchema = z.object({
organisationId: z.string(),
data: z.object({
name: ZOrganisationNameSchema.optional(),
url: ZTeamUrlSchema.optional(),
claims: ZCreateSubscriptionClaimRequestSchema.pick({
teamCount: true,
memberCount: true,
flags: true,
}).optional(),
customerId: z.string().optional(),
originalSubscriptionClaimId: z.string().optional(),
}),
});
export const ZUpdateAdminOrganisationResponseSchema = z.void();
export type TUpdateAdminOrganisationRequest = z.infer<typeof ZUpdateAdminOrganisationRequestSchema>;

View File

@ -0,0 +1,65 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { jobsClient } from '@documenso/lib/jobs/client';
import type { TClaimFlags } from '@documenso/lib/types/subscription';
import { prisma } from '@documenso/prisma';
import { adminProcedure } from '../trpc';
import {
ZUpdateSubscriptionClaimRequestSchema,
ZUpdateSubscriptionClaimResponseSchema,
} from './update-subscription-claim.types';
export const updateSubscriptionClaimRoute = adminProcedure
.input(ZUpdateSubscriptionClaimRequestSchema)
.output(ZUpdateSubscriptionClaimResponseSchema)
.mutation(async ({ input }) => {
const { id, data } = input;
const existingClaim = await prisma.subscriptionClaim.findUnique({
where: { id },
});
if (!existingClaim) {
throw new AppError(AppErrorCode.NOT_FOUND, { message: 'Subscription claim not found' });
}
const newlyEnabledFlags = getNewTruthyFlags(existingClaim.flags, data.flags);
await prisma.$transaction(async (tx) => {
await tx.subscriptionClaim.update({
where: {
id,
},
data,
});
if (Object.keys(newlyEnabledFlags).length > 0) {
await jobsClient.triggerJob({
name: 'internal.backport-subscription-claims',
payload: {
subscriptionClaimId: id,
flags: newlyEnabledFlags,
},
});
}
});
});
function getNewTruthyFlags(
a: Partial<TClaimFlags>,
b: Partial<TClaimFlags>,
): Record<keyof TClaimFlags, true> {
const flags: { [key in keyof TClaimFlags]?: true } = {};
for (const key in b) {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const typedKey = key as keyof TClaimFlags;
if (b[typedKey] === true && a[typedKey] !== true) {
flags[typedKey] = true;
}
}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return flags as Record<keyof TClaimFlags, true>;
}

View File

@ -0,0 +1,12 @@
import { z } from 'zod';
import { ZCreateSubscriptionClaimRequestSchema } from './create-subscription-claim.types';
export const ZUpdateSubscriptionClaimRequestSchema = z.object({
id: z.string(),
data: ZCreateSubscriptionClaimRequestSchema,
});
export const ZUpdateSubscriptionClaimResponseSchema = z.void();
export type TUpdateSubscriptionClaimRequest = z.infer<typeof ZUpdateSubscriptionClaimRequestSchema>;