mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 16:23:06 +10:00
feat: add public profiles
This commit is contained in:
@ -1,3 +1,5 @@
|
||||
import { env } from 'next-runtime-env';
|
||||
|
||||
export enum STRIPE_CUSTOMER_TYPE {
|
||||
INDIVIDUAL = 'individual',
|
||||
TEAM = 'team',
|
||||
@ -8,3 +10,6 @@ export enum STRIPE_PLAN_TYPE {
|
||||
COMMUNITY = 'community',
|
||||
ENTERPRISE = 'enterprise',
|
||||
}
|
||||
|
||||
export const STRIPE_COMMUNITY_PLAN_PRODUCT_ID = () =>
|
||||
env('NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_PRODUCT_ID');
|
||||
|
||||
@ -25,6 +25,7 @@ export const LOCAL_FEATURE_FLAGS: Record<string, boolean> = {
|
||||
app_billing: NEXT_PUBLIC_FEATURE_BILLING_ENABLED() === 'true',
|
||||
app_document_page_view_history_sheet: false,
|
||||
app_passkey: WEBAPP_BASE_URL === 'http://localhost:3000', // Temp feature flag.
|
||||
app_public_profile: true,
|
||||
marketing_header_single_player_mode: false,
|
||||
marketing_profiles_announcement_bar: true,
|
||||
} as const;
|
||||
|
||||
162
packages/lib/server-only/profile/get-public-profile-by-url.ts
Normal file
162
packages/lib/server-only/profile/get-public-profile-by-url.ts
Normal file
@ -0,0 +1,162 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { Template, TemplateDirectLink } from '@documenso/prisma/client';
|
||||
import {
|
||||
SubscriptionStatus,
|
||||
type TeamProfile,
|
||||
TemplateType,
|
||||
type UserProfile,
|
||||
} from '@documenso/prisma/client';
|
||||
|
||||
import { IS_BILLING_ENABLED } from '../../constants/app';
|
||||
import { STRIPE_COMMUNITY_PLAN_PRODUCT_ID } from '../../constants/billing';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { subscriptionsContainsActiveProductId } from '../../utils/billing';
|
||||
|
||||
export type GetPublicProfileByUrlOptions = {
|
||||
profileUrl: string;
|
||||
};
|
||||
|
||||
type PublicDirectLinkTemplate = Template & {
|
||||
type: 'PUBLIC';
|
||||
directLink: TemplateDirectLink & {
|
||||
enabled: true;
|
||||
};
|
||||
};
|
||||
|
||||
type BaseResponse = {
|
||||
url: string;
|
||||
name: string;
|
||||
badge?: 'Premium' | 'EarlySupporter';
|
||||
templates: PublicDirectLinkTemplate[];
|
||||
};
|
||||
|
||||
type GetPublicProfileByUrlResponse = BaseResponse &
|
||||
(
|
||||
| {
|
||||
type: 'User';
|
||||
profile: UserProfile;
|
||||
}
|
||||
| {
|
||||
type: 'Team';
|
||||
profile: TeamProfile;
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Get the user or team public profile by URL.
|
||||
*/
|
||||
export const getPublicProfileByUrl = async ({
|
||||
profileUrl,
|
||||
}: GetPublicProfileByUrlOptions): Promise<GetPublicProfileByUrlResponse> => {
|
||||
const [user, team] = await Promise.all([
|
||||
prisma.user.findFirst({
|
||||
where: {
|
||||
url: profileUrl,
|
||||
profile: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
profile: true,
|
||||
Template: {
|
||||
where: {
|
||||
directLink: {
|
||||
enabled: true,
|
||||
},
|
||||
type: TemplateType.PUBLIC,
|
||||
},
|
||||
include: {
|
||||
directLink: true,
|
||||
},
|
||||
},
|
||||
// Subscriptions and _count are used to calculate the badges.
|
||||
Subscription: {
|
||||
where: {
|
||||
status: SubscriptionStatus.ACTIVE,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
teamMembers: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.team.findFirst({
|
||||
where: {
|
||||
url: profileUrl,
|
||||
profile: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
profile: true,
|
||||
templates: {
|
||||
where: {
|
||||
directLink: {
|
||||
enabled: true,
|
||||
},
|
||||
type: TemplateType.PUBLIC,
|
||||
},
|
||||
include: {
|
||||
directLink: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
// Log as critical error.
|
||||
if (user?.profile && team?.profile) {
|
||||
console.error('Profile URL is ambiguous', { profileUrl, userId: user.id, teamId: team.id });
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, 'Profile URL is ambiguous');
|
||||
}
|
||||
|
||||
if (user?.profile?.enabled) {
|
||||
let badge: BaseResponse['badge'] = undefined;
|
||||
|
||||
if (user._count.teamMembers > 0) {
|
||||
badge = 'Premium';
|
||||
}
|
||||
|
||||
const earlyAdopterProductId = STRIPE_COMMUNITY_PLAN_PRODUCT_ID();
|
||||
|
||||
if (IS_BILLING_ENABLED() && earlyAdopterProductId) {
|
||||
const isEarlyAdopter = subscriptionsContainsActiveProductId(user.Subscription, [
|
||||
earlyAdopterProductId,
|
||||
]);
|
||||
|
||||
if (isEarlyAdopter) {
|
||||
badge = 'EarlySupporter';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'User',
|
||||
badge,
|
||||
profile: user.profile,
|
||||
url: profileUrl,
|
||||
name: user.name || '',
|
||||
templates: user.Template.filter(
|
||||
(template): template is PublicDirectLinkTemplate =>
|
||||
template.directLink?.enabled === true && template.type === TemplateType.PUBLIC,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (team?.profile?.enabled) {
|
||||
return {
|
||||
type: 'Team',
|
||||
badge: 'Premium',
|
||||
profile: team.profile,
|
||||
url: profileUrl,
|
||||
name: team.name || '',
|
||||
templates: team.templates.filter(
|
||||
(template): template is PublicDirectLinkTemplate =>
|
||||
template.directLink?.enabled === true && template.type === TemplateType.PUBLIC,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, 'Profile not found');
|
||||
};
|
||||
@ -76,21 +76,36 @@ export const createTeam = async ({
|
||||
try {
|
||||
// Create the team directly if no payment is required.
|
||||
if (!isPaymentRequired) {
|
||||
await prisma.team.create({
|
||||
data: {
|
||||
name: teamName,
|
||||
url: teamUrl,
|
||||
ownerUserId: user.id,
|
||||
customerId,
|
||||
members: {
|
||||
create: [
|
||||
{
|
||||
userId,
|
||||
role: TeamMemberRole.ADMIN,
|
||||
},
|
||||
],
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const existingUserProfileWithUrl = await tx.user.findUnique({
|
||||
where: {
|
||||
url: teamUrl,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingUserProfileWithUrl) {
|
||||
throw new AppError(AppErrorCode.ALREADY_EXISTS, 'URL already taken.');
|
||||
}
|
||||
|
||||
await tx.team.create({
|
||||
data: {
|
||||
name: teamName,
|
||||
url: teamUrl,
|
||||
ownerUserId: user.id,
|
||||
customerId,
|
||||
members: {
|
||||
create: [
|
||||
{
|
||||
userId,
|
||||
role: TeamMemberRole.ADMIN,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
@ -106,6 +121,19 @@ export const createTeam = async ({
|
||||
},
|
||||
});
|
||||
|
||||
const existingUserProfileWithUrl = await tx.user.findUnique({
|
||||
where: {
|
||||
url: teamUrl,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingUserProfileWithUrl) {
|
||||
throw new AppError(AppErrorCode.ALREADY_EXISTS, 'URL already taken.');
|
||||
}
|
||||
|
||||
if (existingTeamWithUrl) {
|
||||
throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Team URL already exists.');
|
||||
}
|
||||
|
||||
63
packages/lib/server-only/team/get-team-public-profile.ts
Normal file
63
packages/lib/server-only/team/get-team-public-profile.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { TeamProfile } from '@documenso/prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { updateTeamPublicProfile } from './update-team-public-profile';
|
||||
|
||||
export type GetTeamPublicProfileOptions = {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
type GetTeamPublicProfileResponse = {
|
||||
profile: TeamProfile;
|
||||
url: string | null;
|
||||
};
|
||||
|
||||
export const getTeamPublicProfile = async ({
|
||||
userId,
|
||||
teamId,
|
||||
}: GetTeamPublicProfileOptions): Promise<GetTeamPublicProfileResponse> => {
|
||||
const team = await prisma.team.findFirst({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
profile: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, 'Team not found');
|
||||
}
|
||||
|
||||
// Create and return the public profile.
|
||||
if (!team.profile) {
|
||||
const { url, profile } = await updateTeamPublicProfile({
|
||||
userId: userId,
|
||||
teamId,
|
||||
data: {
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
if (!profile) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, 'Failed to create public profile');
|
||||
}
|
||||
|
||||
return {
|
||||
profile,
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
profile: team.profile,
|
||||
url: team.url,
|
||||
};
|
||||
};
|
||||
38
packages/lib/server-only/team/update-team-public-profile.ts
Normal file
38
packages/lib/server-only/team/update-team-public-profile.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type UpdatePublicProfileOptions = {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
data: {
|
||||
bio?: string;
|
||||
enabled?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export const updateTeamPublicProfile = async ({
|
||||
userId,
|
||||
teamId,
|
||||
data,
|
||||
}: UpdatePublicProfileOptions) => {
|
||||
return await prisma.team.update({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
data: {
|
||||
profile: {
|
||||
upsert: {
|
||||
create: data,
|
||||
update: data,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
profile: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -1,11 +1,12 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { Prisma } from '@documenso/prisma/client';
|
||||
import type { Prisma, Template } from '@documenso/prisma/client';
|
||||
|
||||
export type FindTemplatesOptions = {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
page: number;
|
||||
perPage: number;
|
||||
type?: Template['type'];
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
};
|
||||
|
||||
export type FindTemplatesResponse = Awaited<ReturnType<typeof findTemplates>>;
|
||||
@ -14,12 +15,14 @@ export type FindTemplateRow = FindTemplatesResponse['templates'][number];
|
||||
export const findTemplates = async ({
|
||||
userId,
|
||||
teamId,
|
||||
type,
|
||||
page = 1,
|
||||
perPage = 10,
|
||||
}: FindTemplatesOptions) => {
|
||||
let whereFilter: Prisma.TemplateWhereInput = {
|
||||
userId,
|
||||
teamId: null,
|
||||
type,
|
||||
};
|
||||
|
||||
if (teamId !== undefined) {
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { TemplateMeta } from '@documenso/prisma/client';
|
||||
import type { Template, TemplateMeta } from '@documenso/prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
|
||||
@ -17,6 +17,9 @@ export type UpdateTemplateSettingsOptions = {
|
||||
title?: string;
|
||||
globalAccessAuth?: TDocumentAccessAuthTypes | null;
|
||||
globalActionAuth?: TDocumentActionAuthTypes | null;
|
||||
publicTitle?: string;
|
||||
publicDescription?: string;
|
||||
type?: Template['type'];
|
||||
};
|
||||
meta?: Partial<Omit<TemplateMeta, 'id' | 'templateId'>>;
|
||||
requestMetadata?: RequestMetadata;
|
||||
@ -29,7 +32,7 @@ export const updateTemplateSettings = async ({
|
||||
meta,
|
||||
data,
|
||||
}: UpdateTemplateSettingsOptions) => {
|
||||
if (!data.title && !data.globalAccessAuth && !data.globalActionAuth) {
|
||||
if (Object.values(data).length === 0) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, 'Missing data to update');
|
||||
}
|
||||
|
||||
@ -61,30 +64,6 @@ export const updateTemplateSettings = async ({
|
||||
documentAuth: template.authOptions,
|
||||
});
|
||||
|
||||
const { templateMeta } = template;
|
||||
|
||||
const isDateSame = (templateMeta?.dateFormat || null) === (meta?.dateFormat || null);
|
||||
const isMessageSame = (templateMeta?.message || null) === (meta?.message || null);
|
||||
const isPasswordSame = (templateMeta?.password || null) === (meta?.password || null);
|
||||
const isSubjectSame = (templateMeta?.subject || null) === (meta?.subject || null);
|
||||
const isRedirectUrlSame = (templateMeta?.redirectUrl || null) === (meta?.redirectUrl || null);
|
||||
const isTimezoneSame = (templateMeta?.timezone || null) === (meta?.timezone || null);
|
||||
|
||||
// Early return to avoid unnecessary updates.
|
||||
if (
|
||||
template.title === data.title &&
|
||||
data.globalAccessAuth === documentAuthOption.globalAccessAuth &&
|
||||
data.globalActionAuth === documentAuthOption.globalActionAuth &&
|
||||
isDateSame &&
|
||||
isMessageSame &&
|
||||
isPasswordSame &&
|
||||
isSubjectSame &&
|
||||
isRedirectUrlSame &&
|
||||
isTimezoneSame
|
||||
) {
|
||||
return template;
|
||||
}
|
||||
|
||||
const documentGlobalAccessAuth = documentAuthOption?.globalAccessAuth ?? null;
|
||||
const documentGlobalActionAuth = documentAuthOption?.globalActionAuth ?? null;
|
||||
|
||||
@ -120,6 +99,9 @@ export const updateTemplateSettings = async ({
|
||||
},
|
||||
data: {
|
||||
title: data.title,
|
||||
type: data.type,
|
||||
publicDescription: data.publicDescription,
|
||||
publicTitle: data.publicTitle,
|
||||
authOptions,
|
||||
templateMeta: {
|
||||
upsert: {
|
||||
|
||||
55
packages/lib/server-only/user/get-user-public-profile.ts
Normal file
55
packages/lib/server-only/user/get-user-public-profile.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { UserProfile } from '@documenso/prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { updatePublicProfile } from './update-public-profile';
|
||||
|
||||
export type GetUserPublicProfileOptions = {
|
||||
userId: number;
|
||||
};
|
||||
|
||||
type GetUserPublicProfileResponse = {
|
||||
profile: UserProfile;
|
||||
url: string | null;
|
||||
};
|
||||
|
||||
export const getUserPublicProfile = async ({
|
||||
userId,
|
||||
}: GetUserPublicProfileOptions): Promise<GetUserPublicProfileResponse> => {
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
include: {
|
||||
profile: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, 'User not found');
|
||||
}
|
||||
|
||||
// Create and return the public profile.
|
||||
if (!user.profile) {
|
||||
const { url, profile } = await updatePublicProfile({
|
||||
userId: user.id,
|
||||
data: {
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
if (!profile) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, 'Failed to create public profile');
|
||||
}
|
||||
|
||||
return {
|
||||
profile,
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
profile: user.profile,
|
||||
url: user.url,
|
||||
};
|
||||
};
|
||||
@ -4,28 +4,65 @@ import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
|
||||
export type UpdatePublicProfileOptions = {
|
||||
userId: number;
|
||||
url: string;
|
||||
data: {
|
||||
url?: string;
|
||||
bio?: string;
|
||||
enabled?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export const updatePublicProfile = async ({ userId, url }: UpdatePublicProfileOptions) => {
|
||||
const isUrlTaken = await prisma.user.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
export const updatePublicProfile = async ({ userId, data }: UpdatePublicProfileOptions) => {
|
||||
if (Object.values(data).length === 0) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, 'Missing data to update');
|
||||
}
|
||||
|
||||
const { url, bio, enabled } = data;
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: {
|
||||
not: userId,
|
||||
},
|
||||
url,
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (isUrlTaken) {
|
||||
throw new AppError(
|
||||
AppErrorCode.PROFILE_URL_TAKEN,
|
||||
'Profile username is taken',
|
||||
'The profile username is already taken',
|
||||
);
|
||||
if (!user) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, 'User not found');
|
||||
}
|
||||
|
||||
const finalUrl = url ?? user.url;
|
||||
|
||||
if (!finalUrl && enabled) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, 'Cannot enable a profile without a URL');
|
||||
}
|
||||
|
||||
if (url) {
|
||||
const isUrlTakenByAnotherUser = await prisma.user.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
where: {
|
||||
id: {
|
||||
not: userId,
|
||||
},
|
||||
url,
|
||||
},
|
||||
});
|
||||
|
||||
const isUrlTakenByAnotherTeam = await prisma.team.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
where: {
|
||||
url,
|
||||
},
|
||||
});
|
||||
|
||||
if (isUrlTakenByAnotherUser || isUrlTakenByAnotherTeam) {
|
||||
throw new AppError(
|
||||
AppErrorCode.PROFILE_URL_TAKEN,
|
||||
'Profile username is taken',
|
||||
'The profile username is already taken',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return await prisma.user.update({
|
||||
@ -34,16 +71,21 @@ export const updatePublicProfile = async ({ userId, url }: UpdatePublicProfileOp
|
||||
},
|
||||
data: {
|
||||
url,
|
||||
userProfile: {
|
||||
profile: {
|
||||
upsert: {
|
||||
create: {
|
||||
bio: '',
|
||||
bio,
|
||||
enabled,
|
||||
},
|
||||
update: {
|
||||
bio: '',
|
||||
bio,
|
||||
enabled,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
profile: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@ -16,6 +16,18 @@ export const subscriptionsContainsActivePlan = (
|
||||
subscription.status === SubscriptionStatus.ACTIVE && priceIds.includes(subscription.priceId),
|
||||
);
|
||||
};
|
||||
/**
|
||||
* 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),
|
||||
);
|
||||
};
|
||||
|
||||
export const subscriptionsContainActiveEnterprisePlan = (
|
||||
subscriptions?: Subscription[],
|
||||
|
||||
15
packages/lib/utils/public-profiles.ts
Normal file
15
packages/lib/utils/public-profiles.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { WEBAPP_BASE_URL } from '../constants/app';
|
||||
|
||||
export const formatUserProfilePath = (
|
||||
profileUrl: string,
|
||||
options: { excludeBaseUrl?: boolean } = {},
|
||||
) => {
|
||||
return `${!options?.excludeBaseUrl ? WEBAPP_BASE_URL : ''}/p/${profileUrl}`;
|
||||
};
|
||||
|
||||
export const formatTeamProfilePath = (
|
||||
profileUrl: string,
|
||||
options: { excludeBaseUrl?: boolean } = {},
|
||||
) => {
|
||||
return `${!options?.excludeBaseUrl ? WEBAPP_BASE_URL : ''}/p/${profileUrl}`;
|
||||
};
|
||||
Reference in New Issue
Block a user