feat: add public profiles (#1180)

## Description

Add public profiles

## Changes

- Add profiles settings page for users and teams
- Add profiles page `/p/<url>`

## Not completed

- Pending tests
- UI changes to promote public profiles (sign up, etc)
This commit is contained in:
Lucas Smith
2024-06-27 22:47:20 +10:00
committed by GitHub
50 changed files with 2781 additions and 164 deletions

View File

@ -0,0 +1,26 @@
import sharp from 'sharp';
import { prisma } from '@documenso/prisma';
export type GetAvatarImageOptions = {
id: string;
};
export const getAvatarImage = async ({ id }: GetAvatarImageOptions) => {
const avatarImage = await prisma.avatarImage.findFirst({
where: {
id,
},
});
if (!avatarImage) {
return null;
}
const bytes = Buffer.from(avatarImage.bytes, 'base64');
return {
contentType: 'image/jpeg',
content: await sharp(bytes).toFormat('jpeg').toBuffer(),
};
};

View File

@ -0,0 +1,181 @@
import { getCommunityPlanPriceIds } from '@documenso/ee/server-only/stripe/get-community-plan-prices';
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 { AppError, AppErrorCode } from '../../errors/app-error';
export type GetPublicProfileByUrlOptions = {
profileUrl: string;
};
type PublicDirectLinkTemplate = Template & {
type: 'PUBLIC';
directLink: TemplateDirectLink & {
enabled: true;
};
};
type BaseResponse = {
url: string;
name: string;
avatarImageId?: string | null;
badge?: {
type: 'Premium' | 'EarlySupporter';
since: Date;
};
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 teamMembers are used to calculate the badges.
Subscription: {
where: {
status: SubscriptionStatus.ACTIVE,
},
},
teamMembers: {
select: {
createdAt: true,
},
orderBy: {
createdAt: 'asc',
},
},
},
}),
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.teamMembers[0]) {
badge = {
type: 'Premium',
since: user.teamMembers[0]['createdAt'],
};
}
if (IS_BILLING_ENABLED()) {
const earlyAdopterPriceIds = await getCommunityPlanPriceIds();
const activeEarlyAdopterSub = user.Subscription.find(
(subscription) =>
subscription.status === SubscriptionStatus.ACTIVE &&
earlyAdopterPriceIds.includes(subscription.priceId),
);
if (activeEarlyAdopterSub) {
badge = {
type: 'EarlySupporter',
since: activeEarlyAdopterSub.createdAt,
};
}
}
return {
type: 'User',
badge,
profile: user.profile,
url: profileUrl,
avatarImageId: user.avatarImageId,
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: {
type: 'Premium',
since: team.createdAt,
},
profile: team.profile,
url: profileUrl,
avatarImageId: team.avatarImageId,
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');
};

View File

@ -0,0 +1,106 @@
import sharp from 'sharp';
import { prisma } from '@documenso/prisma';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
export type SetAvatarImageOptions = {
userId: number;
teamId?: number | null;
bytes?: string | null;
requestMetadata?: RequestMetadata;
};
export const setAvatarImage = async ({
userId,
teamId,
bytes,
requestMetadata,
}: SetAvatarImageOptions) => {
let oldAvatarImageId: string | null = null;
const user = await prisma.user.findUnique({
where: {
id: userId,
},
include: {
avatarImage: true,
},
});
if (!user) {
throw new Error('User not found');
}
oldAvatarImageId = user.avatarImageId;
if (teamId) {
const team = await prisma.team.findFirst({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
});
if (!team) {
throw new Error('Team not found');
}
oldAvatarImageId = team.avatarImageId;
}
if (oldAvatarImageId) {
await prisma.avatarImage.delete({
where: {
id: oldAvatarImageId,
},
});
}
let newAvatarImageId: string | null = null;
if (bytes) {
const optimisedBytes = await sharp(Buffer.from(bytes, 'base64'))
.resize(512, 512)
.toFormat('jpeg', { quality: 75 })
.toBuffer();
const avatarImage = await prisma.avatarImage.create({
data: {
bytes: optimisedBytes.toString('base64'),
},
});
newAvatarImageId = avatarImage.id;
}
if (teamId) {
await prisma.team.update({
where: {
id: teamId,
},
data: {
avatarImageId: newAvatarImageId,
},
});
// TODO: Audit Logs
} else {
await prisma.user.update({
where: {
id: userId,
},
data: {
avatarImageId: newAvatarImageId,
},
});
// TODO: Audit Logs
}
return newAvatarImageId;
};

View File

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

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

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

View File

@ -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) {

View File

@ -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: {

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

View File

@ -4,28 +4,61 @@ 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, 'The profile username is already taken');
}
}
return await prisma.user.update({
@ -34,16 +67,21 @@ export const updatePublicProfile = async ({ userId, url }: UpdatePublicProfileOp
},
data: {
url,
userProfile: {
profile: {
upsert: {
create: {
bio: '',
bio,
enabled,
},
update: {
bio: '',
bio,
enabled,
},
},
},
},
include: {
profile: true,
},
});
};